summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:13:28 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:13:31 +0900
commit2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch)
tree94e1923180ae38d571d34b578afecb0a18913c24 /src/features/heartbeat
parent80f99665034a0e510300793205c162fc7a46769f (diff)
parent08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff)
downloaddispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz
dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip
Merge branch 'dev' into feature/heartbeat
# Conflicts: # src/app/App.svelte # src/app/store.svelte.ts # src/app/store.test.ts # src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/heartbeat')
-rw-r--r--src/features/heartbeat/index.ts76
-rw-r--r--src/features/heartbeat/logic/types.ts70
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts842
-rw-r--r--src/features/heartbeat/logic/view-model.ts380
-rw-r--r--src/features/heartbeat/ui/PromptEditor.test.ts284
5 files changed, 826 insertions, 826 deletions
diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts
index fd1cf8d..cc438f1 100644
--- a/src/features/heartbeat/index.ts
+++ b/src/features/heartbeat/index.ts
@@ -1,43 +1,43 @@
export type {
- HeartbeatConfig,
- HeartbeatConfigPatch,
- HeartbeatConfigResult,
- HeartbeatNextRunResult,
- HeartbeatRun,
- HeartbeatRunStatus,
- HeartbeatRunsResult,
- HeartbeatStopResult,
- LoadHeartbeatConfig,
- LoadHeartbeatNextRun,
- LoadHeartbeatRuns,
- SaveHeartbeatConfig,
- StopHeartbeatRun,
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRun,
+ HeartbeatRunStatus,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+ LoadHeartbeatConfig,
+ LoadHeartbeatNextRun,
+ LoadHeartbeatRuns,
+ SaveHeartbeatConfig,
+ StopHeartbeatRun,
} from "./logic/types";
export type { Badge, HeartbeatFormState, HeartbeatRunView } from "./logic/view-model";
export {
- approximateNextRunEpoch,
- badgeForStatus,
- DEFAULT_INTERVAL_MINUTES,
- effectiveSystemPrompt,
- effortOptions,
- emptyForm,
- formatCountdown,
- formatRunTime,
- formDiffers,
- formFromConfig,
- isInheritingSystemPrompt,
- joinInterval,
- nextRunEpoch,
- normalizeHeartbeatConfig,
- normalizeHeartbeatRuns,
- normalizeInterval,
- patchFromForm,
- persistedSystemPrompt,
- relativeLabel,
- splitInterval,
- statusLabelFor,
- viewRun,
- viewRuns,
+ approximateNextRunEpoch,
+ badgeForStatus,
+ DEFAULT_INTERVAL_MINUTES,
+ effectiveSystemPrompt,
+ effortOptions,
+ emptyForm,
+ formatCountdown,
+ formatRunTime,
+ formDiffers,
+ formFromConfig,
+ isInheritingSystemPrompt,
+ joinInterval,
+ nextRunEpoch,
+ normalizeHeartbeatConfig,
+ normalizeHeartbeatRuns,
+ normalizeInterval,
+ patchFromForm,
+ persistedSystemPrompt,
+ relativeLabel,
+ splitInterval,
+ statusLabelFor,
+ viewRun,
+ viewRuns,
} from "./logic/view-model";
export { default as HeartbeatView } from "./ui/HeartbeatView.svelte";
export { default as PromptEditor } from "./ui/PromptEditor.svelte";
@@ -45,6 +45,6 @@ export { default as RunModal } from "./ui/RunModal.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "heartbeat",
- description: "Workspace autonomous-agent heartbeat: config, run history, live run chat",
+ name: "heartbeat",
+ description: "Workspace autonomous-agent heartbeat: config, run history, live run chat",
} as const;
diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts
index ede22a6..3d3d525 100644
--- a/src/features/heartbeat/logic/types.ts
+++ b/src/features/heartbeat/logic/types.ts
@@ -24,20 +24,20 @@ export type HeartbeatRunStatus = "running" | "completed" | "stopped";
/** The workspace's heartbeat configuration (`GET /workspaces/:id/heartbeat`). */
export interface HeartbeatConfig {
- /** Whether the autonomous loop is enabled (running on the interval). */
- readonly enabled: boolean;
- readonly systemPrompt: string;
- readonly taskPrompt: string;
- /** Minutes between runs. */
- readonly intervalMinutes: number;
- /** The model name (`<credential>/<model>`) the heartbeat runs with. */
- readonly model: string;
- /**
- * The heartbeat's reasoning effort, or null when never set (the server
- * default `"high"` then applies) — mirrors the per-conversation knob's
- * resolution chain.
- */
- readonly reasoningEffort: ReasoningEffort | null;
+ /** Whether the autonomous loop is enabled (running on the interval). */
+ readonly enabled: boolean;
+ readonly systemPrompt: string;
+ readonly taskPrompt: string;
+ /** Minutes between runs. */
+ readonly intervalMinutes: number;
+ /** The model name (`<credential>/<model>`) the heartbeat runs with. */
+ readonly model: string;
+ /**
+ * The heartbeat's reasoning effort, or null when never set (the server
+ * default `"high"` then applies) — mirrors the per-conversation knob's
+ * resolution chain.
+ */
+ readonly reasoningEffort: ReasoningEffort | null;
}
/**
@@ -45,22 +45,22 @@ export interface HeartbeatConfig {
* optional — the backend merges the patch onto the stored config.
*/
export interface HeartbeatConfigPatch {
- readonly enabled?: boolean;
- readonly systemPrompt?: string;
- readonly taskPrompt?: string;
- readonly intervalMinutes?: number;
- readonly model?: string;
- readonly reasoningEffort?: ReasoningEffort | null;
+ readonly enabled?: boolean;
+ readonly systemPrompt?: string;
+ readonly taskPrompt?: string;
+ readonly intervalMinutes?: number;
+ readonly model?: string;
+ readonly reasoningEffort?: ReasoningEffort | null;
}
/** One heartbeat run (`GET /workspaces/:id/heartbeat/runs`). */
export interface HeartbeatRun {
- readonly id: string;
- /** The conversation this run wrote to (watch it live for the chat). */
- readonly conversationId: string;
- /** ISO timestamp of when the run was triggered. */
- readonly triggeredAt: string;
- readonly status: HeartbeatRunStatus;
+ readonly id: string;
+ /** The conversation this run wrote to (watch it live for the chat). */
+ readonly conversationId: string;
+ /** ISO timestamp of when the run was triggered. */
+ readonly triggeredAt: string;
+ readonly status: HeartbeatRunStatus;
}
// ── Injected ports (consumer-defines-port; the composition root adapts the
@@ -68,22 +68,22 @@ export interface HeartbeatRun {
/** Outcome of `GET /workspaces/:id/heartbeat` (or the PUT response). */
export type HeartbeatConfigResult =
- | { readonly ok: true; readonly config: HeartbeatConfig }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly config: HeartbeatConfig }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `GET /workspaces/:id/heartbeat/runs`. */
export type HeartbeatRunsResult =
- | { readonly ok: true; readonly runs: readonly HeartbeatRun[] }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly runs: readonly HeartbeatRun[] }
+ | { readonly ok: false; readonly error: string };
/** Outcome of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */
export type HeartbeatStopResult =
- | { readonly ok: true }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true }
+ | { readonly ok: false; readonly error: string };
export type LoadHeartbeatConfig = () => Promise<HeartbeatConfigResult | null>;
export type SaveHeartbeatConfig = (
- patch: HeartbeatConfigPatch,
+ patch: HeartbeatConfigPatch,
) => Promise<HeartbeatConfigResult | null>;
export type LoadHeartbeatRuns = () => Promise<HeartbeatRunsResult | null>;
export type StopHeartbeatRun = (runId: string) => Promise<HeartbeatStopResult | null>;
@@ -97,7 +97,7 @@ export type StopHeartbeatRun = (runId: string) => Promise<HeartbeatStopResult |
* approximation from the runs + config (see `approximateNextRunEpoch`).
*/
export type HeartbeatNextRunResult =
- | { readonly ok: true; readonly nextRunAt: string | null }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly nextRunAt: string | null }
+ | { readonly ok: false; readonly error: string };
export type LoadHeartbeatNextRun = () => Promise<HeartbeatNextRunResult | null>;
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts
index d237817..aca0aa6 100644
--- a/src/features/heartbeat/logic/view-model.test.ts
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -2,469 +2,469 @@ import type { ReasoningEffort } from "@dispatch/transport-contract";
import { describe, expect, it } from "vitest";
import type { HeartbeatConfig, HeartbeatRun } from "./types";
import {
- approximateNextRunEpoch,
- badgeForStatus,
- DEFAULT_INTERVAL_MINUTES,
- effectiveSystemPrompt,
- effortOptions,
- emptyForm,
- formatCountdown,
- formatRunTime,
- formDiffers,
- formFromConfig,
- isInheritingSystemPrompt,
- joinInterval,
- nextRunEpoch,
- normalizeHeartbeatConfig,
- normalizeHeartbeatRuns,
- normalizeInterval,
- patchFromForm,
- persistedSystemPrompt,
- relativeLabel,
- splitInterval,
- statusLabelFor,
- viewRun,
- viewRuns,
+ approximateNextRunEpoch,
+ badgeForStatus,
+ DEFAULT_INTERVAL_MINUTES,
+ effectiveSystemPrompt,
+ effortOptions,
+ emptyForm,
+ formatCountdown,
+ formatRunTime,
+ formDiffers,
+ formFromConfig,
+ isInheritingSystemPrompt,
+ joinInterval,
+ nextRunEpoch,
+ normalizeHeartbeatConfig,
+ normalizeHeartbeatRuns,
+ normalizeInterval,
+ patchFromForm,
+ persistedSystemPrompt,
+ relativeLabel,
+ splitInterval,
+ statusLabelFor,
+ viewRun,
+ viewRuns,
} from "./view-model";
const NOW = Date.UTC(2026, 5, 25, 14, 30, 5); // 2026-06-25T14:30:05Z
const ISO_AT = "2026-06-25T14:30:05Z"; // exactly NOW
const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({
- id: "run-1",
- conversationId: "conv-1",
- triggeredAt: ISO_AT,
- status: "completed",
- ...over,
+ id: "run-1",
+ conversationId: "conv-1",
+ triggeredAt: ISO_AT,
+ status: "completed",
+ ...over,
});
const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({
- enabled: false,
- systemPrompt: "be helpful",
- taskPrompt: "check status",
- intervalMinutes: 15,
- model: "openai/gpt-4o",
- reasoningEffort: null,
- ...over,
+ enabled: false,
+ systemPrompt: "be helpful",
+ taskPrompt: "check status",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: null,
+ ...over,
});
describe("badgeForStatus", () => {
- it("running → warning + busy (spinner)", () => {
- expect(badgeForStatus("running")).toEqual({ badge: "warning", busy: true });
- });
- it("completed → success, not busy", () => {
- expect(badgeForStatus("completed")).toEqual({ badge: "success", busy: false });
- });
- it("stopped → neutral, not busy", () => {
- expect(badgeForStatus("stopped")).toEqual({ badge: "neutral", busy: false });
- });
+ it("running → warning + busy (spinner)", () => {
+ expect(badgeForStatus("running")).toEqual({ badge: "warning", busy: true });
+ });
+ it("completed → success, not busy", () => {
+ expect(badgeForStatus("completed")).toEqual({ badge: "success", busy: false });
+ });
+ it("stopped → neutral, not busy", () => {
+ expect(badgeForStatus("stopped")).toEqual({ badge: "neutral", busy: false });
+ });
});
describe("statusLabelFor", () => {
- it("maps each status to a display label", () => {
- expect(statusLabelFor("running")).toBe("Running");
- expect(statusLabelFor("completed")).toBe("Completed");
- expect(statusLabelFor("stopped")).toBe("Stopped");
- });
+ it("maps each status to a display label", () => {
+ expect(statusLabelFor("running")).toBe("Running");
+ expect(statusLabelFor("completed")).toBe("Completed");
+ expect(statusLabelFor("stopped")).toBe("Stopped");
+ });
});
describe("formatRunTime", () => {
- it("formats an ISO timestamp as HH:MM:SS (UTC components)", () => {
- // Uses local getHours/Minutes/Seconds; under UTC env (TZ=UTC) reads 14:30:05.
- // We assert the SHAPE (3 colon-separated 2-digit groups) so it's TZ-stable.
- expect(formatRunTime(ISO_AT)).toMatch(/^\d{2}:\d{2}:\d{2}$/);
- expect(formatRunTime(ISO_AT).split(":")).toHaveLength(3);
- });
- it("returns — for an unparseable timestamp", () => {
- expect(formatRunTime("not-a-date")).toBe("—");
- expect(formatRunTime("")).toBe("—");
- });
+ it("formats an ISO timestamp as HH:MM:SS (UTC components)", () => {
+ // Uses local getHours/Minutes/Seconds; under UTC env (TZ=UTC) reads 14:30:05.
+ // We assert the SHAPE (3 colon-separated 2-digit groups) so it's TZ-stable.
+ expect(formatRunTime(ISO_AT)).toMatch(/^\d{2}:\d{2}:\d{2}$/);
+ expect(formatRunTime(ISO_AT).split(":")).toHaveLength(3);
+ });
+ it("returns — for an unparseable timestamp", () => {
+ expect(formatRunTime("not-a-date")).toBe("—");
+ expect(formatRunTime("")).toBe("—");
+ });
});
describe("relativeLabel", () => {
- it("just now when within a minute", () => {
- expect(relativeLabel(ISO_AT, NOW)).toBe("just now");
- expect(relativeLabel(ISO_AT, NOW + 30_000)).toBe("just now");
- });
- it("Nm ago under an hour", () => {
- expect(relativeLabel(ISO_AT, NOW + 5 * 60_000)).toBe("5m ago");
- expect(relativeLabel(ISO_AT, NOW + 59 * 60_000)).toBe("59m ago");
- });
- it("Nh ago under a day", () => {
- expect(relativeLabel(ISO_AT, NOW + 2 * 3_600_000)).toBe("2h ago");
- });
- it("absolute date+time past a day", () => {
- const label = relativeLabel(ISO_AT, NOW + 26 * 3_600_000);
- expect(label).toMatch(/^[A-Z][a-z]{2} \d+, \d{2}:\d{2}$/);
- });
- it("future timestamp → just now (clock skew tolerance)", () => {
- expect(relativeLabel(ISO_AT, NOW - 10_000)).toBe("just now");
- });
- it("returns — for an unparseable timestamp", () => {
- expect(relativeLabel("nope", NOW)).toBe("—");
- });
+ it("just now when within a minute", () => {
+ expect(relativeLabel(ISO_AT, NOW)).toBe("just now");
+ expect(relativeLabel(ISO_AT, NOW + 30_000)).toBe("just now");
+ });
+ it("Nm ago under an hour", () => {
+ expect(relativeLabel(ISO_AT, NOW + 5 * 60_000)).toBe("5m ago");
+ expect(relativeLabel(ISO_AT, NOW + 59 * 60_000)).toBe("59m ago");
+ });
+ it("Nh ago under a day", () => {
+ expect(relativeLabel(ISO_AT, NOW + 2 * 3_600_000)).toBe("2h ago");
+ });
+ it("absolute date+time past a day", () => {
+ const label = relativeLabel(ISO_AT, NOW + 26 * 3_600_000);
+ expect(label).toMatch(/^[A-Z][a-z]{2} \d+, \d{2}:\d{2}$/);
+ });
+ it("future timestamp → just now (clock skew tolerance)", () => {
+ expect(relativeLabel(ISO_AT, NOW - 10_000)).toBe("just now");
+ });
+ it("returns — for an unparseable timestamp", () => {
+ expect(relativeLabel("nope", NOW)).toBe("—");
+ });
});
describe("viewRun / viewRuns", () => {
- it("running run: warning badge + busy + labels", () => {
- const v = viewRun(run({ status: "running" }), NOW);
- expect(v.badge).toBe("warning");
- expect(v.busy).toBe(true);
- expect(v.statusLabel).toBe("Running");
- expect(v.id).toBe("run-1");
- expect(v.conversationId).toBe("conv-1");
- expect(v.timeLabel).toMatch(/^\d{2}:\d{2}:\d{2}$/);
- expect(v.relativeLabel).toBe("just now");
- });
- it("completed run: success badge, not busy", () => {
- expect(viewRun(run({ status: "completed" }), NOW).badge).toBe("success");
- });
- it("stopped run: neutral badge, not busy", () => {
- expect(viewRun(run({ status: "stopped" }), NOW).badge).toBe("neutral");
- });
- it("viewRuns preserves order", () => {
- const views = viewRuns([run({ id: "a" }), run({ id: "b" })], NOW);
- expect(views.map((v) => v.id)).toEqual(["a", "b"]);
- });
+ it("running run: warning badge + busy + labels", () => {
+ const v = viewRun(run({ status: "running" }), NOW);
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ expect(v.statusLabel).toBe("Running");
+ expect(v.id).toBe("run-1");
+ expect(v.conversationId).toBe("conv-1");
+ expect(v.timeLabel).toMatch(/^\d{2}:\d{2}:\d{2}$/);
+ expect(v.relativeLabel).toBe("just now");
+ });
+ it("completed run: success badge, not busy", () => {
+ expect(viewRun(run({ status: "completed" }), NOW).badge).toBe("success");
+ });
+ it("stopped run: neutral badge, not busy", () => {
+ expect(viewRun(run({ status: "stopped" }), NOW).badge).toBe("neutral");
+ });
+ it("viewRuns preserves order", () => {
+ const views = viewRuns([run({ id: "a" }), run({ id: "b" })], NOW);
+ expect(views.map((v) => v.id)).toEqual(["a", "b"]);
+ });
});
describe("config form", () => {
- it("emptyForm has defaults (disabled, default interval split, default effort)", () => {
- const f = emptyForm();
- expect(f.enabled).toBe(false);
- // 30 min → 0h 30m
- expect(f.intervalHours).toBe(0);
- expect(f.intervalMinutes).toBe(30);
- expect(f.reasoningEffort).toBe("high"); // DEFAULT_REASONING_EFFORT
- expect(f.systemPrompt).toBe("");
- expect(f.model).toBe("");
- });
-
- it("formFromConfig resolves null reasoningEffort to the default", () => {
- const f = formFromConfig(config({ reasoningEffort: null }));
- expect(f.reasoningEffort).toBe("high");
- });
-
- it("formFromConfig passes through a set reasoningEffort", () => {
- const f = formFromConfig(config({ reasoningEffort: "max" }));
- expect(f.reasoningEffort).toBe("max");
- });
-
- it("formFromConfig splits intervalMinutes into hours + minutes (0–59)", () => {
- expect(formFromConfig(config({ intervalMinutes: 90 }))).toMatchObject({
- intervalHours: 1,
- intervalMinutes: 30,
- });
- expect(formFromConfig(config({ intervalMinutes: 60 }))).toMatchObject({
- intervalHours: 1,
- intervalMinutes: 0,
- });
- expect(formFromConfig(config({ intervalMinutes: 59 }))).toMatchObject({
- intervalHours: 0,
- intervalMinutes: 59,
- });
- expect(formFromConfig(config({ intervalMinutes: 1440 }))).toMatchObject({
- intervalHours: 24,
- intervalMinutes: 0,
- });
- });
-
- it("formFromConfig coerces malformed fields safely", () => {
- const f = formFromConfig(
- config({
- enabled: "yes" as unknown as boolean,
- intervalMinutes: -5,
- model: 42 as unknown as string,
- systemPrompt: undefined as unknown as string,
- }),
- );
- expect(f.enabled).toBe(false); // non-true → false
- // -5 clamps to 1 → 0h 1m
- expect(f.intervalHours).toBe(0);
- expect(f.intervalMinutes).toBe(1);
- expect(f.model).toBe(""); // non-string → ""
- expect(f.systemPrompt).toBe(""); // undefined → ""
- });
-
- it("normalizeInterval clamps to 1–1440 and rounds", () => {
- expect(normalizeInterval(0)).toBe(1);
- expect(normalizeInterval(-10)).toBe(1);
- expect(normalizeInterval(1.4)).toBe(1);
- expect(normalizeInterval(15.6)).toBe(16);
- expect(normalizeInterval(2000)).toBe(1440);
- expect(normalizeInterval("30" as unknown as number)).toBe(30); // default on non-number
- expect(normalizeInterval(undefined)).toBe(DEFAULT_INTERVAL_MINUTES);
- });
-
- it("splitInterval / joinInterval round-trip (and clamp)", () => {
- expect(splitInterval(90)).toEqual({ hours: 1, minutes: 30 });
- expect(splitInterval(0)).toEqual({ hours: 0, minutes: 1 }); // 0 → clamps to 1
- expect(splitInterval(1440)).toEqual({ hours: 24, minutes: 0 });
- expect(splitInterval(2000)).toEqual({ hours: 24, minutes: 0 }); // clamped
- // join recomputes + clamps
- expect(joinInterval(1, 30)).toBe(90);
- expect(joinInterval(0, 0)).toBe(1); // 0 → clamps to 1
- expect(joinInterval(25, 0)).toBe(1440); // 1500 → clamps to 1440
- expect(joinInterval(-1, 30)).toBe(30); // negatives floored to 0
- expect(joinInterval("x" as unknown as number, 15)).toBe(15); // non-finite → 0h
- });
-
- it("patchFromForm recombines hours+minutes into intervalMinutes + carries every field", () => {
- const f = formFromConfig(config({ intervalMinutes: 2000 }));
- // 2000 clamps to 1440 → 24h 0m in the form
- expect(f.intervalHours).toBe(24);
- expect(f.intervalMinutes).toBe(0);
- const patch = patchFromForm(f);
- expect(patch.intervalMinutes).toBe(1440);
- expect(patch.enabled).toBe(false);
- expect(patch.model).toBe("openai/gpt-4o");
- expect(patch.reasoningEffort).toBe("high");
- expect(patch.systemPrompt).toBe("be helpful");
- expect(patch.taskPrompt).toBe("check status");
- });
-
- it("patchFromForm recombines an arbitrary hours/minutes edit", () => {
- const f = formFromConfig(config({ intervalMinutes: 15 }));
- f.intervalHours = 2;
- f.intervalMinutes = 45;
- expect(patchFromForm(f).intervalMinutes).toBe(165);
- });
-
- it("formDiffers is false for a form seeded from the config (no edits)", () => {
- const c = config({ reasoningEffort: "medium" });
- const f = formFromConfig(c);
- expect(formDiffers(f, c)).toBe(false);
- });
-
- it("formDiffers is true after an edit", () => {
- const c = config();
- const f = formFromConfig(c);
- f.systemPrompt = "changed";
- expect(formDiffers(f, c)).toBe(true);
- });
-
- it("formDiffers is true after an interval edit (hours or minutes)", () => {
- const c = config({ intervalMinutes: 90 });
- const f = formFromConfig(c);
- f.intervalMinutes = 45; // 1h45m vs 1h30m
- expect(formDiffers(f, c)).toBe(true);
- });
-
- it("formDiffers treats null config effort as the default (matches the resolved form)", () => {
- const c = config({ reasoningEffort: null });
- const f = formFromConfig(c);
- expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form
- });
+ it("emptyForm has defaults (disabled, default interval split, default effort)", () => {
+ const f = emptyForm();
+ expect(f.enabled).toBe(false);
+ // 30 min → 0h 30m
+ expect(f.intervalHours).toBe(0);
+ expect(f.intervalMinutes).toBe(30);
+ expect(f.reasoningEffort).toBe("high"); // DEFAULT_REASONING_EFFORT
+ expect(f.systemPrompt).toBe("");
+ expect(f.model).toBe("");
+ });
+
+ it("formFromConfig resolves null reasoningEffort to the default", () => {
+ const f = formFromConfig(config({ reasoningEffort: null }));
+ expect(f.reasoningEffort).toBe("high");
+ });
+
+ it("formFromConfig passes through a set reasoningEffort", () => {
+ const f = formFromConfig(config({ reasoningEffort: "max" }));
+ expect(f.reasoningEffort).toBe("max");
+ });
+
+ it("formFromConfig splits intervalMinutes into hours + minutes (0–59)", () => {
+ expect(formFromConfig(config({ intervalMinutes: 90 }))).toMatchObject({
+ intervalHours: 1,
+ intervalMinutes: 30,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 60 }))).toMatchObject({
+ intervalHours: 1,
+ intervalMinutes: 0,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 59 }))).toMatchObject({
+ intervalHours: 0,
+ intervalMinutes: 59,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 1440 }))).toMatchObject({
+ intervalHours: 24,
+ intervalMinutes: 0,
+ });
+ });
+
+ it("formFromConfig coerces malformed fields safely", () => {
+ const f = formFromConfig(
+ config({
+ enabled: "yes" as unknown as boolean,
+ intervalMinutes: -5,
+ model: 42 as unknown as string,
+ systemPrompt: undefined as unknown as string,
+ }),
+ );
+ expect(f.enabled).toBe(false); // non-true → false
+ // -5 clamps to 1 → 0h 1m
+ expect(f.intervalHours).toBe(0);
+ expect(f.intervalMinutes).toBe(1);
+ expect(f.model).toBe(""); // non-string → ""
+ expect(f.systemPrompt).toBe(""); // undefined → ""
+ });
+
+ it("normalizeInterval clamps to 1–1440 and rounds", () => {
+ expect(normalizeInterval(0)).toBe(1);
+ expect(normalizeInterval(-10)).toBe(1);
+ expect(normalizeInterval(1.4)).toBe(1);
+ expect(normalizeInterval(15.6)).toBe(16);
+ expect(normalizeInterval(2000)).toBe(1440);
+ expect(normalizeInterval("30" as unknown as number)).toBe(30); // default on non-number
+ expect(normalizeInterval(undefined)).toBe(DEFAULT_INTERVAL_MINUTES);
+ });
+
+ it("splitInterval / joinInterval round-trip (and clamp)", () => {
+ expect(splitInterval(90)).toEqual({ hours: 1, minutes: 30 });
+ expect(splitInterval(0)).toEqual({ hours: 0, minutes: 1 }); // 0 → clamps to 1
+ expect(splitInterval(1440)).toEqual({ hours: 24, minutes: 0 });
+ expect(splitInterval(2000)).toEqual({ hours: 24, minutes: 0 }); // clamped
+ // join recomputes + clamps
+ expect(joinInterval(1, 30)).toBe(90);
+ expect(joinInterval(0, 0)).toBe(1); // 0 → clamps to 1
+ expect(joinInterval(25, 0)).toBe(1440); // 1500 → clamps to 1440
+ expect(joinInterval(-1, 30)).toBe(30); // negatives floored to 0
+ expect(joinInterval("x" as unknown as number, 15)).toBe(15); // non-finite → 0h
+ });
+
+ it("patchFromForm recombines hours+minutes into intervalMinutes + carries every field", () => {
+ const f = formFromConfig(config({ intervalMinutes: 2000 }));
+ // 2000 clamps to 1440 → 24h 0m in the form
+ expect(f.intervalHours).toBe(24);
+ expect(f.intervalMinutes).toBe(0);
+ const patch = patchFromForm(f);
+ expect(patch.intervalMinutes).toBe(1440);
+ expect(patch.enabled).toBe(false);
+ expect(patch.model).toBe("openai/gpt-4o");
+ expect(patch.reasoningEffort).toBe("high");
+ expect(patch.systemPrompt).toBe("be helpful");
+ expect(patch.taskPrompt).toBe("check status");
+ });
+
+ it("patchFromForm recombines an arbitrary hours/minutes edit", () => {
+ const f = formFromConfig(config({ intervalMinutes: 15 }));
+ f.intervalHours = 2;
+ f.intervalMinutes = 45;
+ expect(patchFromForm(f).intervalMinutes).toBe(165);
+ });
+
+ it("formDiffers is false for a form seeded from the config (no edits)", () => {
+ const c = config({ reasoningEffort: "medium" });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false);
+ });
+
+ it("formDiffers is true after an edit", () => {
+ const c = config();
+ const f = formFromConfig(c);
+ f.systemPrompt = "changed";
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers is true after an interval edit (hours or minutes)", () => {
+ const c = config({ intervalMinutes: 90 });
+ const f = formFromConfig(c);
+ f.intervalMinutes = 45; // 1h45m vs 1h30m
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers treats null config effort as the default (matches the resolved form)", () => {
+ const c = config({ reasoningEffort: null });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == 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("");
- });
+ 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();
- const values = opts.map((o) => o.value) as readonly string[];
- expect(values).toEqual(["low", "medium", "high", "xhigh", "max"]);
- const def = opts.find((o) => o.value === "high");
- expect(def?.label).toBe("high (default)");
- });
+ it("exposes the canonical ladder with the default marked", () => {
+ const opts = effortOptions();
+ const values = opts.map((o) => o.value) as readonly string[];
+ expect(values).toEqual(["low", "medium", "high", "xhigh", "max"]);
+ const def = opts.find((o) => o.value === "high");
+ expect(def?.label).toBe("high (default)");
+ });
});
describe("reasoningEffort type narrowing (sanity)", () => {
- // Ensures the imported ladder stays the wire's canonical set — if the wire
- // ladder changes, this test flags the drift alongside the chat feature.
- it("the five canonical levels", () => {
- const levels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
- expect(levels).toHaveLength(5);
- });
+ // Ensures the imported ladder stays the wire's canonical set — if the wire
+ // ladder changes, this test flags the drift alongside the chat feature.
+ it("the five canonical levels", () => {
+ const levels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
+ expect(levels).toHaveLength(5);
+ });
});
describe("normalizeHeartbeatConfig", () => {
- it("passes through a well-formed config", () => {
- const c = normalizeHeartbeatConfig({
- enabled: true,
- systemPrompt: "sys",
- taskPrompt: "task",
- intervalMinutes: 20,
- model: "openai/gpt-4o",
- reasoningEffort: "max",
- });
- expect(c).toEqual({
- enabled: true,
- systemPrompt: "sys",
- taskPrompt: "task",
- intervalMinutes: 20,
- model: "openai/gpt-4o",
- reasoningEffort: "max",
- });
- });
- it("coerces a malformed body safely (never throws, never undefined)", () => {
- const c = normalizeHeartbeatConfig({
- enabled: "yes",
- intervalMinutes: -3,
- reasoningEffort: "bogus",
- });
- expect(c.enabled).toBe(false);
- expect(c.intervalMinutes).toBe(1);
- expect(c.reasoningEffort).toBeNull();
- expect(c.systemPrompt).toBe("");
- expect(c.taskPrompt).toBe("");
- expect(c.model).toBe("");
- });
- it("accepts a null reasoningEffort", () => {
- expect(normalizeHeartbeatConfig({ reasoningEffort: null }).reasoningEffort).toBeNull();
- });
- it("handles null / non-object input", () => {
- const c = normalizeHeartbeatConfig(null);
- expect(c.enabled).toBe(false);
- expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES);
- expect(c.model).toBe("");
- });
- it("clamps a huge interval", () => {
- expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440);
- });
+ it("passes through a well-formed config", () => {
+ const c = normalizeHeartbeatConfig({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 20,
+ model: "openai/gpt-4o",
+ reasoningEffort: "max",
+ });
+ expect(c).toEqual({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 20,
+ model: "openai/gpt-4o",
+ reasoningEffort: "max",
+ });
+ });
+ it("coerces a malformed body safely (never throws, never undefined)", () => {
+ const c = normalizeHeartbeatConfig({
+ enabled: "yes",
+ intervalMinutes: -3,
+ reasoningEffort: "bogus",
+ });
+ expect(c.enabled).toBe(false);
+ expect(c.intervalMinutes).toBe(1);
+ expect(c.reasoningEffort).toBeNull();
+ expect(c.systemPrompt).toBe("");
+ expect(c.taskPrompt).toBe("");
+ expect(c.model).toBe("");
+ });
+ it("accepts a null reasoningEffort", () => {
+ expect(normalizeHeartbeatConfig({ reasoningEffort: null }).reasoningEffort).toBeNull();
+ });
+ it("handles null / non-object input", () => {
+ const c = normalizeHeartbeatConfig(null);
+ expect(c.enabled).toBe(false);
+ expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES);
+ expect(c.model).toBe("");
+ });
+ it("clamps a huge interval", () => {
+ expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440);
+ });
});
describe("normalizeHeartbeatRuns", () => {
- it("maps a well-formed runs list", () => {
- const runs = normalizeHeartbeatRuns({
- runs: [
- { id: "r1", conversationId: "c1", triggeredAt: "2026-06-25T10:00:00Z", status: "running" },
- {
- id: "r2",
- conversationId: "c2",
- triggeredAt: "2026-06-25T09:00:00Z",
- status: "completed",
- },
- ],
- });
- expect(runs).toHaveLength(2);
- expect(runs[0]).toMatchObject({ id: "r1", status: "running" });
- expect(runs[1]).toMatchObject({ id: "r2", status: "completed" });
- });
- it("returns [] for malformed body", () => {
- expect(normalizeHeartbeatRuns(null)).toEqual([]);
- expect(normalizeHeartbeatRuns({})).toEqual([]);
- expect(normalizeHeartbeatRuns({ runs: "nope" })).toEqual([]);
- });
- it("drops runs missing id/conversationId and defaults unknown status", () => {
- const runs = normalizeHeartbeatRuns({
- runs: [
- { id: "r1", conversationId: "c1", triggeredAt: "x", status: "garbage" },
- { id: "", conversationId: "c2", triggeredAt: "x", status: "completed" },
- { id: "r3", conversationId: "", triggeredAt: "x", status: "running" },
- { id: "r4", conversationId: "c4", triggeredAt: "x", status: "stopped" },
- ],
- });
- expect(runs).toHaveLength(2);
- expect(runs[0]?.status).toBe("completed"); // "garbage" → default
- expect(runs[0]?.id).toBe("r1");
- expect(runs[1]?.id).toBe("r4");
- });
+ it("maps a well-formed runs list", () => {
+ const runs = normalizeHeartbeatRuns({
+ runs: [
+ { id: "r1", conversationId: "c1", triggeredAt: "2026-06-25T10:00:00Z", status: "running" },
+ {
+ id: "r2",
+ conversationId: "c2",
+ triggeredAt: "2026-06-25T09:00:00Z",
+ status: "completed",
+ },
+ ],
+ });
+ expect(runs).toHaveLength(2);
+ expect(runs[0]).toMatchObject({ id: "r1", status: "running" });
+ expect(runs[1]).toMatchObject({ id: "r2", status: "completed" });
+ });
+ it("returns [] for malformed body", () => {
+ expect(normalizeHeartbeatRuns(null)).toEqual([]);
+ expect(normalizeHeartbeatRuns({})).toEqual([]);
+ expect(normalizeHeartbeatRuns({ runs: "nope" })).toEqual([]);
+ });
+ it("drops runs missing id/conversationId and defaults unknown status", () => {
+ const runs = normalizeHeartbeatRuns({
+ runs: [
+ { id: "r1", conversationId: "c1", triggeredAt: "x", status: "garbage" },
+ { id: "", conversationId: "c2", triggeredAt: "x", status: "completed" },
+ { id: "r3", conversationId: "", triggeredAt: "x", status: "running" },
+ { id: "r4", conversationId: "c4", triggeredAt: "x", status: "stopped" },
+ ],
+ });
+ expect(runs).toHaveLength(2);
+ expect(runs[0]?.status).toBe("completed"); // "garbage" → default
+ expect(runs[0]?.id).toBe("r1");
+ expect(runs[1]?.id).toBe("r4");
+ });
});
describe("next-run countdown", () => {
- const ISO_AT = "2026-06-25T14:05:00Z"; // 5 min past the hour
-
- describe("nextRunEpoch", () => {
- it("parses an ISO timestamp to epoch-ms", () => {
- expect(nextRunEpoch(ISO_AT)).toBe(Date.parse(ISO_AT));
- });
- it("returns null for unparseable / empty / non-string", () => {
- expect(nextRunEpoch("not-a-date")).toBeNull();
- expect(nextRunEpoch("")).toBeNull();
- expect(nextRunEpoch(null)).toBeNull();
- expect(nextRunEpoch(undefined)).toBeNull();
- });
- });
-
- describe("formatCountdown", () => {
- it("null → —", () => {
- expect(formatCountdown(null)).toBe("—");
- });
- it("≤ 0 → due", () => {
- expect(formatCountdown(0)).toBe("due");
- expect(formatCountdown(-5000)).toBe("due");
- });
- it("seconds only (< 1m)", () => {
- expect(formatCountdown(32_000)).toBe("32s");
- expect(formatCountdown(1_000)).toBe("1s");
- });
- it("minutes + seconds (1m–1h)", () => {
- expect(formatCountdown(4 * 60_000 + 32_000)).toBe("4m 32s");
- expect(formatCountdown(59 * 60_000 + 5_000)).toBe("59m 05s");
- });
- it("hours + minutes (≥ 1h)", () => {
- expect(formatCountdown(3_600_000 + 5 * 60_000)).toBe("1h 05m");
- expect(formatCountdown(2 * 3_600_000 + 30 * 60_000)).toBe("2h 30m");
- });
- });
-
- describe("approximateNextRunEpoch", () => {
- const runs = (times: string[]): HeartbeatRun[] =>
- times.map((t, i) => ({
- id: `r${i}`,
- conversationId: "c",
- triggeredAt: t,
- status: "completed",
- }));
-
- it("disabled → null", () => {
- expect(approximateNextRunEpoch(runs([ISO_AT]), 15, false)).toBeNull();
- });
- it("no runs → null (no fabricated countdown)", () => {
- expect(approximateNextRunEpoch([], 15, true)).toBeNull();
- });
- it("latest run + interval (minutes)", () => {
- // latest is the max triggeredAt (runs need not be ordered)
- const unordered = runs(["2026-06-25T13:00:00Z", "2026-06-25T13:50:00Z"]);
- // 13:50 + 15 min = 14:05
- expect(approximateNextRunEpoch(unordered, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
- });
- it("ignores unparseable triggeredAt values", () => {
- const mixed = runs(["not-a-date", "2026-06-25T13:50:00Z"]);
- expect(approximateNextRunEpoch(mixed, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
- });
- it("all-unparseable → null", () => {
- expect(approximateNextRunEpoch(runs(["nope", "also-nope"]), 15, true)).toBeNull();
- });
- });
+ const ISO_AT = "2026-06-25T14:05:00Z"; // 5 min past the hour
+
+ describe("nextRunEpoch", () => {
+ it("parses an ISO timestamp to epoch-ms", () => {
+ expect(nextRunEpoch(ISO_AT)).toBe(Date.parse(ISO_AT));
+ });
+ it("returns null for unparseable / empty / non-string", () => {
+ expect(nextRunEpoch("not-a-date")).toBeNull();
+ expect(nextRunEpoch("")).toBeNull();
+ expect(nextRunEpoch(null)).toBeNull();
+ expect(nextRunEpoch(undefined)).toBeNull();
+ });
+ });
+
+ describe("formatCountdown", () => {
+ it("null → —", () => {
+ expect(formatCountdown(null)).toBe("—");
+ });
+ it("≤ 0 → due", () => {
+ expect(formatCountdown(0)).toBe("due");
+ expect(formatCountdown(-5000)).toBe("due");
+ });
+ it("seconds only (< 1m)", () => {
+ expect(formatCountdown(32_000)).toBe("32s");
+ expect(formatCountdown(1_000)).toBe("1s");
+ });
+ it("minutes + seconds (1m–1h)", () => {
+ expect(formatCountdown(4 * 60_000 + 32_000)).toBe("4m 32s");
+ expect(formatCountdown(59 * 60_000 + 5_000)).toBe("59m 05s");
+ });
+ it("hours + minutes (≥ 1h)", () => {
+ expect(formatCountdown(3_600_000 + 5 * 60_000)).toBe("1h 05m");
+ expect(formatCountdown(2 * 3_600_000 + 30 * 60_000)).toBe("2h 30m");
+ });
+ });
+
+ describe("approximateNextRunEpoch", () => {
+ const runs = (times: string[]): HeartbeatRun[] =>
+ times.map((t, i) => ({
+ id: `r${i}`,
+ conversationId: "c",
+ triggeredAt: t,
+ status: "completed",
+ }));
+
+ it("disabled → null", () => {
+ expect(approximateNextRunEpoch(runs([ISO_AT]), 15, false)).toBeNull();
+ });
+ it("no runs → null (no fabricated countdown)", () => {
+ expect(approximateNextRunEpoch([], 15, true)).toBeNull();
+ });
+ it("latest run + interval (minutes)", () => {
+ // latest is the max triggeredAt (runs need not be ordered)
+ const unordered = runs(["2026-06-25T13:00:00Z", "2026-06-25T13:50:00Z"]);
+ // 13:50 + 15 min = 14:05
+ expect(approximateNextRunEpoch(unordered, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
+ });
+ it("ignores unparseable triggeredAt values", () => {
+ const mixed = runs(["not-a-date", "2026-06-25T13:50:00Z"]);
+ expect(approximateNextRunEpoch(mixed, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
+ });
+ it("all-unparseable → null", () => {
+ expect(approximateNextRunEpoch(runs(["nope", "also-nope"]), 15, true)).toBeNull();
+ });
+ });
});
diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts
index 93c0e55..e91febd 100644
--- a/src/features/heartbeat/logic/view-model.ts
+++ b/src/features/heartbeat/logic/view-model.ts
@@ -1,14 +1,14 @@
import type { ReasoningEffort } from "@dispatch/transport-contract";
import {
- DEFAULT_REASONING_EFFORT,
- effectiveEffort,
- effortOptions,
+ DEFAULT_REASONING_EFFORT,
+ effectiveEffort,
+ effortOptions,
} from "../../chat/reasoning-effort";
import type {
- HeartbeatConfig,
- HeartbeatConfigPatch,
- HeartbeatRun,
- HeartbeatRunStatus,
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatRun,
+ HeartbeatRunStatus,
} from "./types";
/**
@@ -26,17 +26,17 @@ export type Badge = "success" | "warning" | "error" | "neutral";
/** A run shaped for display in the scrolling runs list. */
export interface HeartbeatRunView {
- readonly id: string;
- readonly conversationId: string;
- readonly status: HeartbeatRunStatus;
- readonly statusLabel: string;
- readonly badge: Badge;
- /** True while the run is in flight (show a spinner). */
- readonly busy: boolean;
- /** A short absolute clock label, e.g. "14:30:05". */
- readonly timeLabel: string;
- /** A relative label, e.g. "5m ago" / "just now". */
- readonly relativeLabel: string;
+ readonly id: string;
+ readonly conversationId: string;
+ readonly status: HeartbeatRunStatus;
+ readonly statusLabel: string;
+ readonly badge: Badge;
+ /** True while the run is in flight (show a spinner). */
+ readonly busy: boolean;
+ /** A short absolute clock label, e.g. "14:30:05". */
+ readonly timeLabel: string;
+ /** A relative label, e.g. "5m ago" / "just now". */
+ readonly relativeLabel: string;
}
const RUNNING_LABEL = "Running";
@@ -49,25 +49,25 @@ const STOPPED_LABEL = "Stopped";
* status visual treatment.
*/
export function badgeForStatus(status: HeartbeatRunStatus): { badge: Badge; busy: boolean } {
- switch (status) {
- case "running":
- return { badge: "warning", busy: true };
- case "completed":
- return { badge: "success", busy: false };
- case "stopped":
- return { badge: "neutral", busy: false };
- }
+ switch (status) {
+ case "running":
+ return { badge: "warning", busy: true };
+ case "completed":
+ return { badge: "success", busy: false };
+ case "stopped":
+ return { badge: "neutral", busy: false };
+ }
}
export function statusLabelFor(status: HeartbeatRunStatus): string {
- switch (status) {
- case "running":
- return RUNNING_LABEL;
- case "completed":
- return COMPLETED_LABEL;
- case "stopped":
- return STOPPED_LABEL;
- }
+ switch (status) {
+ case "running":
+ return RUNNING_LABEL;
+ case "completed":
+ return COMPLETED_LABEL;
+ case "stopped":
+ return STOPPED_LABEL;
+ }
}
/**
@@ -77,9 +77,9 @@ export function statusLabelFor(status: HeartbeatRunStatus): string {
* clock label doesn't depend on the current time).
*/
export function formatRunTime(triggeredAt: string): string {
- const t = parseTime(triggeredAt);
- if (t === null) return "—";
- return clockLabel(t);
+ const t = parseTime(triggeredAt);
+ if (t === null) return "—";
+ return clockLabel(t);
}
/**
@@ -87,16 +87,16 @@ export function formatRunTime(triggeredAt: string): string {
* absolute date+time (so an old run reads "Jun 24, 14:30"). Pure via `now`.
*/
export function relativeLabel(triggeredAt: string, now: number = Date.now()): string {
- const t = parseTime(triggeredAt);
- if (t === null) return "—";
- const deltaMs = now - t;
- if (deltaMs < 0) return "just now";
- const mins = Math.floor(deltaMs / 60000);
- if (mins < 1) return "just now";
- if (mins < 60) return `${mins}m ago`;
- const hours = Math.floor(mins / 60);
- if (hours < 24) return `${hours}h ago`;
- return dateLabel(t);
+ const t = parseTime(triggeredAt);
+ if (t === null) return "—";
+ const deltaMs = now - t;
+ if (deltaMs < 0) return "just now";
+ const mins = Math.floor(deltaMs / 60000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return dateLabel(t);
}
/**
@@ -104,52 +104,52 @@ export function relativeLabel(triggeredAt: string, now: number = Date.now()): st
* `Date.now()`); the composition-root component passes nothing in production.
*/
export function viewRun(run: HeartbeatRun, now: number = Date.now()): HeartbeatRunView {
- const { badge, busy } = badgeForStatus(run.status);
- return {
- id: run.id,
- conversationId: run.conversationId,
- status: run.status,
- statusLabel: statusLabelFor(run.status),
- badge,
- busy,
- timeLabel: formatRunTime(run.triggeredAt),
- relativeLabel: relativeLabel(run.triggeredAt, now),
- };
+ const { badge, busy } = badgeForStatus(run.status);
+ return {
+ id: run.id,
+ conversationId: run.conversationId,
+ status: run.status,
+ statusLabel: statusLabelFor(run.status),
+ badge,
+ busy,
+ timeLabel: formatRunTime(run.triggeredAt),
+ relativeLabel: relativeLabel(run.triggeredAt, now),
+ };
}
export function viewRuns(
- runs: readonly HeartbeatRun[],
- now: number = Date.now(),
+ runs: readonly HeartbeatRun[],
+ now: number = Date.now(),
): readonly HeartbeatRunView[] {
- return runs.map((r) => viewRun(r, now));
+ return runs.map((r) => viewRun(r, now));
}
// ── Time formatting (pure: no `Date` mutation; injectable `now` for tests) ─────
/** Parse an ISO timestamp to epoch ms, or null if unparseable. */
function parseTime(iso: string): number | null {
- if (typeof iso !== "string" || iso.length === 0) return null;
- const t = Date.parse(iso);
- return Number.isNaN(t) ? null : t;
+ if (typeof iso !== "string" || iso.length === 0) return null;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? null : t;
}
/** `HH:MM:SS` in the viewer's locale (24h where the locale uses it). */
function clockLabel(epochMs: number): string {
- const d = new Date(epochMs);
- const hh = String(d.getHours()).padStart(2, "0");
- const mm = String(d.getMinutes()).padStart(2, "0");
- const ss = String(d.getSeconds()).padStart(2, "0");
- return `${hh}:${mm}:${ss}`;
+ const d = new Date(epochMs);
+ const hh = String(d.getHours()).padStart(2, "0");
+ const mm = String(d.getMinutes()).padStart(2, "0");
+ const ss = String(d.getSeconds()).padStart(2, "0");
+ return `${hh}:${mm}:${ss}`;
}
/** A short absolute date+time label for an old run, e.g. "Jun 24, 14:30". */
function dateLabel(epochMs: number): string {
- const d = new Date(epochMs);
- const month = d.toLocaleString(undefined, { month: "short" });
- const day = d.getDate();
- const hh = String(d.getHours()).padStart(2, "0");
- const mm = String(d.getMinutes()).padStart(2, "0");
- return `${month} ${day}, ${hh}:${mm}`;
+ const d = new Date(epochMs);
+ const month = d.toLocaleString(undefined, { month: "short" });
+ const day = d.getDate();
+ const hh = String(d.getHours()).padStart(2, "0");
+ const mm = String(d.getMinutes()).padStart(2, "0");
+ return `${month} ${day}, ${hh}:${mm}`;
}
// ── Next-run countdown (timer of when the next heartbeat fires) ───────────────
@@ -162,9 +162,9 @@ function dateLabel(epochMs: number): string {
/** Parse an ISO timestamp to epoch-ms, or null if unparseable. */
export function nextRunEpoch(iso: string | null | undefined): number | null {
- if (typeof iso !== "string" || iso.length === 0) return null;
- const t = Date.parse(iso);
- return Number.isNaN(t) ? null : t;
+ if (typeof iso !== "string" || iso.length === 0) return null;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? null : t;
}
/**
@@ -172,15 +172,15 @@ export function nextRunEpoch(iso: string | null | undefined): number | null {
* "due" (≤ 0), or "—" (unknown/null). Pure via the injected `remainingMs`.
*/
export function formatCountdown(remainingMs: number | null): string {
- if (remainingMs === null) return "—";
- if (remainingMs <= 0) return "due";
- const totalSec = Math.floor(remainingMs / 1000);
- const hours = Math.floor(totalSec / 3600);
- const mins = Math.floor((totalSec % 3600) / 60);
- const secs = totalSec % 60;
- if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`;
- if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`;
- return `${secs}s`;
+ if (remainingMs === null) return "—";
+ if (remainingMs <= 0) return "due";
+ const totalSec = Math.floor(remainingMs / 1000);
+ const hours = Math.floor(totalSec / 3600);
+ const mins = Math.floor((totalSec % 3600) / 60);
+ const secs = totalSec % 60;
+ if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`;
+ if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`;
+ return `${secs}s`;
}
/**
@@ -192,18 +192,18 @@ export function formatCountdown(remainingMs: number | null): string {
* run is latest + interval, independent of the current time).
*/
export function approximateNextRunEpoch(
- runs: readonly HeartbeatRun[],
- intervalMinutes: number,
- enabled: boolean,
+ runs: readonly HeartbeatRun[],
+ intervalMinutes: number,
+ enabled: boolean,
): number | null {
- if (!enabled) return null;
- let latest: number | null = null;
- for (const r of runs) {
- const t = Date.parse(r.triggeredAt);
- if (!Number.isNaN(t) && (latest === null || t > latest)) latest = t;
- }
- if (latest === null) return null;
- return latest + intervalMinutes * 60_000;
+ if (!enabled) return null;
+ let latest: number | null = null;
+ for (const r of runs) {
+ const t = Date.parse(r.triggeredAt);
+ if (!Number.isNaN(t) && (latest === null || t > latest)) latest = t;
+ }
+ if (latest === null) return null;
+ return latest + intervalMinutes * 60_000;
}
// ── Config form ───────────────────────────────────────────────────────────────
@@ -219,13 +219,13 @@ export function approximateNextRunEpoch(
* (`patchFromForm`); the backend stores a single `intervalMinutes`.
*/
export interface HeartbeatFormState {
- enabled: boolean;
- systemPrompt: string;
- taskPrompt: string;
- intervalHours: number;
- intervalMinutes: number;
- model: string;
- reasoningEffort: ReasoningEffort;
+ enabled: boolean;
+ systemPrompt: string;
+ taskPrompt: string;
+ intervalHours: number;
+ intervalMinutes: number;
+ model: string;
+ reasoningEffort: ReasoningEffort;
}
/** The default interval (minutes) shown for an empty/unset config. */
@@ -233,17 +233,17 @@ export const DEFAULT_INTERVAL_MINUTES = 30;
/** Split a total-minutes value into { hours, minutes (0–59) }. Pure. */
export function splitInterval(totalMinutes: number): { hours: number; minutes: number } {
- const total = normalizeInterval(totalMinutes);
- const hours = Math.floor(total / 60);
- const minutes = total - hours * 60;
- return { hours, minutes };
+ const total = normalizeInterval(totalMinutes);
+ const hours = Math.floor(total / 60);
+ const minutes = total - hours * 60;
+ return { hours, minutes };
}
/** Recombine hours + minutes into a clamped total-minutes value. Pure. */
export function joinInterval(hours: number, minutes: number): number {
- const h = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0;
- const m = Number.isFinite(minutes) ? Math.max(0, Math.floor(minutes)) : 0;
- return normalizeInterval(h * 60 + m);
+ const h = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0;
+ const m = Number.isFinite(minutes) ? Math.max(0, Math.floor(minutes)) : 0;
+ return normalizeInterval(h * 60 + m);
}
/**
@@ -251,39 +251,39 @@ export function joinInterval(hours: number, minutes: number): number {
* any malformed/absent backend field so the inputs are never `undefined`.
*/
export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState {
- const { hours, minutes } = splitInterval(config.intervalMinutes);
- return {
- enabled: config.enabled === true,
- systemPrompt: config.systemPrompt ?? "",
- taskPrompt: config.taskPrompt ?? "",
- intervalHours: hours,
- intervalMinutes: minutes,
- model: typeof config.model === "string" ? config.model : "",
- reasoningEffort: effectiveEffort(config.reasoningEffort ?? null),
- };
+ const { hours, minutes } = splitInterval(config.intervalMinutes);
+ return {
+ enabled: config.enabled === true,
+ systemPrompt: config.systemPrompt ?? "",
+ taskPrompt: config.taskPrompt ?? "",
+ intervalHours: hours,
+ intervalMinutes: minutes,
+ model: typeof config.model === "string" ? config.model : "",
+ reasoningEffort: effectiveEffort(config.reasoningEffort ?? null),
+ };
}
/** An empty form (before the config loads). */
export function emptyForm(): HeartbeatFormState {
- const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES);
- return {
- enabled: false,
- systemPrompt: "",
- taskPrompt: "",
- intervalHours: hours,
- intervalMinutes: minutes,
- model: "",
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- };
+ const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES);
+ return {
+ enabled: false,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalHours: hours,
+ intervalMinutes: minutes,
+ model: "",
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
+ };
}
/** Clamp a raw interval to a sane positive-minute range (1–1440 = 1 min–24 h). */
export function normalizeInterval(value: unknown): number {
- const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_INTERVAL_MINUTES;
- const int = Math.round(n);
- if (int < 1) return 1;
- if (int > 1440) return 1440;
- return int;
+ const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_INTERVAL_MINUTES;
+ const int = Math.round(n);
+ if (int < 1) return 1;
+ if (int > 1440) return 1440;
+ return int;
}
/**
@@ -293,28 +293,28 @@ export function normalizeInterval(value: unknown): number {
* heartbeat has no per-run override — it persists the level.
*/
export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch {
- return {
- enabled: form.enabled,
- systemPrompt: form.systemPrompt,
- taskPrompt: form.taskPrompt,
- intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes),
- model: form.model,
- reasoningEffort: form.reasoningEffort,
- };
+ return {
+ enabled: form.enabled,
+ systemPrompt: form.systemPrompt,
+ taskPrompt: form.taskPrompt,
+ intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes),
+ model: form.model,
+ reasoningEffort: form.reasoningEffort,
+ };
}
/** Whether the form differs from the loaded config (drives the Save button). */
export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): boolean {
- const { hours, minutes } = splitInterval(config.intervalMinutes);
- return (
- form.enabled !== config.enabled ||
- form.systemPrompt !== (config.systemPrompt ?? "") ||
- form.taskPrompt !== (config.taskPrompt ?? "") ||
- form.intervalHours !== hours ||
- form.intervalMinutes !== minutes ||
- form.model !== (typeof config.model === "string" ? config.model : "") ||
- form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null)
- );
+ const { hours, minutes } = splitInterval(config.intervalMinutes);
+ return (
+ form.enabled !== config.enabled ||
+ form.systemPrompt !== (config.systemPrompt ?? "") ||
+ form.taskPrompt !== (config.taskPrompt ?? "") ||
+ form.intervalHours !== hours ||
+ form.intervalMinutes !== minutes ||
+ form.model !== (typeof config.model === "string" ? config.model : "") ||
+ form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null)
+ );
}
// ── System-prompt inheritance (heartbeat override ⇄ global default) ────────────
@@ -332,12 +332,12 @@ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig):
* 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;
+ return override !== "" ? override : defaultPrompt;
}
/** Whether the heartbeat is inheriting the global default (empty override). */
export function isInheritingSystemPrompt(override: string): boolean {
- return override === "";
+ return override === "";
}
/**
@@ -348,8 +348,8 @@ export function isInheritingSystemPrompt(override: string): boolean {
* — never duplicating the default into the heartbeat config.
*/
export function persistedSystemPrompt(editable: string, defaultPrompt: string): string {
- if (editable === "" || editable === defaultPrompt) return "";
- return editable;
+ if (editable === "" || editable === defaultPrompt) return "";
+ return editable;
}
// The reasoning-effort `<option>`s are reused verbatim from the per-conversation
@@ -365,46 +365,46 @@ export { effortOptions };
/** Narrow an untrusted string to the run-status enum, defaulting to "completed". */
function asRunStatus(value: unknown): HeartbeatRunStatus {
- if (value === "running" || value === "completed" || value === "stopped") return value;
- return "completed";
+ if (value === "running" || value === "completed" || value === "stopped") return value;
+ return "completed";
}
/** Coerce an untrusted `GET .../heartbeat/runs` body into a typed run list. */
export function normalizeHeartbeatRuns(data: unknown): readonly HeartbeatRun[] {
- if (!isRecord(data) || !Array.isArray(data.runs)) return [];
- const runs = data.runs as readonly unknown[];
- return runs
- .filter((r): r is Record<string, unknown> => r !== null && typeof r === "object")
- .map((r) => ({
- id: typeof r.id === "string" ? r.id : "",
- conversationId: typeof r.conversationId === "string" ? r.conversationId : "",
- triggeredAt: typeof r.triggeredAt === "string" ? r.triggeredAt : "",
- status: asRunStatus(r.status),
- }))
- .filter((r) => r.id !== "" && r.conversationId !== "");
+ if (!isRecord(data) || !Array.isArray(data.runs)) return [];
+ const runs = data.runs as readonly unknown[];
+ return runs
+ .filter((r): r is Record<string, unknown> => r !== null && typeof r === "object")
+ .map((r) => ({
+ id: typeof r.id === "string" ? r.id : "",
+ conversationId: typeof r.conversationId === "string" ? r.conversationId : "",
+ triggeredAt: typeof r.triggeredAt === "string" ? r.triggeredAt : "",
+ status: asRunStatus(r.status),
+ }))
+ .filter((r) => r.id !== "" && r.conversationId !== "");
}
/** Coerce an untrusted `GET`/`PUT .../heartbeat` body into a typed config. */
export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig {
- const d = isRecord(data) ? data : {};
- const effort = d.reasoningEffort;
- return {
- enabled: d.enabled === true,
- systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "",
- taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "",
- intervalMinutes: normalizeInterval(d.intervalMinutes),
- model: typeof d.model === "string" ? d.model : "",
- reasoningEffort:
- effort === "low" ||
- effort === "medium" ||
- effort === "high" ||
- effort === "xhigh" ||
- effort === "max"
- ? effort
- : null,
- };
+ const d = isRecord(data) ? data : {};
+ const effort = d.reasoningEffort;
+ return {
+ enabled: d.enabled === true,
+ systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "",
+ taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "",
+ intervalMinutes: normalizeInterval(d.intervalMinutes),
+ model: typeof d.model === "string" ? d.model : "",
+ reasoningEffort:
+ effort === "low" ||
+ effort === "medium" ||
+ effort === "high" ||
+ effort === "xhigh" ||
+ effort === "max"
+ ? effort
+ : null,
+ };
}
function isRecord(value: unknown): value is Record<string, unknown> {
- return value !== null && typeof value === "object";
+ return value !== null && typeof value === "object";
}
diff --git a/src/features/heartbeat/ui/PromptEditor.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts
index 4b5c960..284b319 100644
--- a/src/features/heartbeat/ui/PromptEditor.test.ts
+++ b/src/features/heartbeat/ui/PromptEditor.test.ts
@@ -2,166 +2,166 @@ import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type {
- HeartbeatConfigPatch,
- HeartbeatConfigResult,
- SaveHeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ SaveHeartbeatConfig,
} from "../logic/types";
import PromptEditor from "./PromptEditor.svelte";
// Fakes for the injected ports.
function fakeLoadVariables() {
- return vi.fn(async () => ({ ok: true, variables: [] }) as const);
+ return vi.fn(async () => ({ ok: true, variables: [] }) as const);
}
function fakeLoadDefaultPrompt(template = "You are a helpful assistant.") {
- return vi.fn(async () => ({ ok: true, template }) as const);
+ return vi.fn(async () => ({ ok: true, template }) as const);
}
/** A capturing saveConfig that resolves ok, echoing the merged config shape. */
function fakeSaveConfig(): {
- calls: HeartbeatConfigPatch[];
- impl: SaveHeartbeatConfig;
+ calls: HeartbeatConfigPatch[];
+ impl: SaveHeartbeatConfig;
} {
- const calls: HeartbeatConfigPatch[] = [];
- const impl: SaveHeartbeatConfig = async (patch) => {
- calls.push(patch);
- // Echo a config that reflects the persisted patch (so onSaved sync is realistic).
- const config = {
- enabled: false,
- systemPrompt: patch.systemPrompt ?? "",
- taskPrompt: patch.taskPrompt ?? "",
- intervalMinutes: 30,
- model: "openai/gpt-4o",
- reasoningEffort: null,
- };
- return { ok: true, config } satisfies HeartbeatConfigResult;
- };
- return { calls, impl };
+ const calls: HeartbeatConfigPatch[] = [];
+ const impl: SaveHeartbeatConfig = async (patch) => {
+ calls.push(patch);
+ // Echo a config that reflects the persisted patch (so onSaved sync is realistic).
+ const config = {
+ enabled: false,
+ systemPrompt: patch.systemPrompt ?? "",
+ taskPrompt: patch.taskPrompt ?? "",
+ intervalMinutes: 30,
+ model: "openai/gpt-4o",
+ reasoningEffort: null,
+ };
+ return { ok: true, config } satisfies HeartbeatConfigResult;
+ };
+ return { calls, impl };
}
const baseProps = (overrides: Record<string, unknown> = {}) => ({
- systemPrompt: "",
- taskPrompt: "",
- loadVariables: fakeLoadVariables(),
- loadDefaultPrompt: fakeLoadDefaultPrompt(),
- saveConfig: fakeSaveConfig().impl,
- onSaved: vi.fn(),
- onClose: vi.fn(),
- ...overrides,
+ systemPrompt: "",
+ taskPrompt: "",
+ loadVariables: fakeLoadVariables(),
+ loadDefaultPrompt: fakeLoadDefaultPrompt(),
+ saveConfig: fakeSaveConfig().impl,
+ onSaved: vi.fn(),
+ onClose: vi.fn(),
+ ...overrides,
});
describe("PromptEditor save flow", () => {
- it("persists an edited system prompt and clears the unsaved state (regression: save flickered + reverted)", async () => {
- const user = userEvent.setup();
- const save = fakeSaveConfig();
- const onSaved = vi.fn();
- render(PromptEditor, {
- props: baseProps({
- // Start inheriting (empty override); the default pre-fills.
- systemPrompt: "",
- saveConfig: save.impl,
- onSaved,
- }),
- });
-
- // Wait for the default to load + pre-fill the system textarea.
- const systemBox = await screen.findByLabelText("Heartbeat system prompt");
- expect(systemBox).toHaveValue("You are a helpful assistant.");
-
- // Save is disabled while it matches the default (no explicit edit).
- expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
-
- // Edit the system prompt → an override.
- await user.clear(systemBox);
- await user.type(systemBox, "custom override");
-
- // Save is now enabled.
- const saveBtn = screen.getByRole("button", { name: "Save" });
- expect(saveBtn).toBeEnabled();
- await user.click(saveBtn);
-
- // The save port was called with the override persisted verbatim.
- expect(save.calls).toHaveLength(1);
- expect(save.calls[0]?.systemPrompt).toBe("custom override");
- expect(onSaved).toHaveBeenCalledWith("custom override", "");
-
- // THE REGRESSION: after save, hasChanges must clear (Save disabled again)
- // and the "Saved." confirmation shows — NOT "Unsaved changes".
- await vi.waitFor(() => {
- expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
- });
- expect(screen.getByText("Saved.")).toBeInTheDocument();
- expect(screen.queryByText(/Unsaved changes/i)).not.toBeInTheDocument();
- });
-
- it("persisting text that matches the default sends '' (inherit) and clears unsaved state", async () => {
- const user = userEvent.setup();
- const save = fakeSaveConfig();
- render(PromptEditor, {
- props: baseProps({
- // Start with an override.
- systemPrompt: "old override",
- saveConfig: save.impl,
- }),
- });
-
- const systemBox = await screen.findByLabelText("Heartbeat system prompt");
- expect(systemBox).toHaveValue("old override");
-
- // Reset to default → text matches the default → saving inherits ("").
- await user.click(screen.getByRole("button", { name: "Reset to default" }));
- expect(systemBox).toHaveValue("You are a helpful assistant.");
-
- const saveBtn = screen.getByRole("button", { name: "Save" });
- expect(saveBtn).toBeEnabled();
- await user.click(saveBtn);
-
- expect(save.calls).toHaveLength(1);
- expect(save.calls[0]?.systemPrompt).toBe(""); // inherit
- await vi.waitFor(() => {
- expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
- });
- expect(screen.getByText("Saved.")).toBeInTheDocument();
- });
-
- it("editing the task prompt saves + clears unsaved state", async () => {
- const user = userEvent.setup();
- const save = fakeSaveConfig();
- render(PromptEditor, {
- props: baseProps({ saveConfig: save.impl }),
- });
-
- const taskBox = await screen.findByLabelText("Heartbeat task prompt");
- await user.type(taskBox, "do the thing");
-
- const saveBtn = screen.getByRole("button", { name: "Save" });
- expect(saveBtn).toBeEnabled();
- await user.click(saveBtn);
-
- expect(save.calls[0]?.taskPrompt).toBe("do the thing");
- await vi.waitFor(() => {
- expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
- });
- expect(screen.getByText("Saved.")).toBeInTheDocument();
- });
-
- it("a failed save surfaces the error and keeps the edit unsaved", async () => {
- const user = userEvent.setup();
- const failingSave: SaveHeartbeatConfig = async () => ({ ok: false, error: "boom" });
- render(PromptEditor, {
- props: baseProps({ saveConfig: failingSave }),
- });
-
- const systemBox = await screen.findByLabelText("Heartbeat system prompt");
- await user.clear(systemBox);
- await user.type(systemBox, "custom");
-
- await user.click(screen.getByRole("button", { name: "Save" }));
-
- expect(screen.getByText("boom")).toBeInTheDocument();
- // Still unsaved (Save stays enabled), no success badge.
- expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
- expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
- });
+ it("persists an edited system prompt and clears the unsaved state (regression: save flickered + reverted)", async () => {
+ const user = userEvent.setup();
+ const save = fakeSaveConfig();
+ const onSaved = vi.fn();
+ render(PromptEditor, {
+ props: baseProps({
+ // Start inheriting (empty override); the default pre-fills.
+ systemPrompt: "",
+ saveConfig: save.impl,
+ onSaved,
+ }),
+ });
+
+ // Wait for the default to load + pre-fill the system textarea.
+ const systemBox = await screen.findByLabelText("Heartbeat system prompt");
+ expect(systemBox).toHaveValue("You are a helpful assistant.");
+
+ // Save is disabled while it matches the default (no explicit edit).
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+
+ // Edit the system prompt → an override.
+ await user.clear(systemBox);
+ await user.type(systemBox, "custom override");
+
+ // Save is now enabled.
+ const saveBtn = screen.getByRole("button", { name: "Save" });
+ expect(saveBtn).toBeEnabled();
+ await user.click(saveBtn);
+
+ // The save port was called with the override persisted verbatim.
+ expect(save.calls).toHaveLength(1);
+ expect(save.calls[0]?.systemPrompt).toBe("custom override");
+ expect(onSaved).toHaveBeenCalledWith("custom override", "");
+
+ // THE REGRESSION: after save, hasChanges must clear (Save disabled again)
+ // and the "Saved." confirmation shows — NOT "Unsaved changes".
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+ expect(screen.getByText("Saved.")).toBeInTheDocument();
+ expect(screen.queryByText(/Unsaved changes/i)).not.toBeInTheDocument();
+ });
+
+ it("persisting text that matches the default sends '' (inherit) and clears unsaved state", async () => {
+ const user = userEvent.setup();
+ const save = fakeSaveConfig();
+ render(PromptEditor, {
+ props: baseProps({
+ // Start with an override.
+ systemPrompt: "old override",
+ saveConfig: save.impl,
+ }),
+ });
+
+ const systemBox = await screen.findByLabelText("Heartbeat system prompt");
+ expect(systemBox).toHaveValue("old override");
+
+ // Reset to default → text matches the default → saving inherits ("").
+ await user.click(screen.getByRole("button", { name: "Reset to default" }));
+ expect(systemBox).toHaveValue("You are a helpful assistant.");
+
+ const saveBtn = screen.getByRole("button", { name: "Save" });
+ expect(saveBtn).toBeEnabled();
+ await user.click(saveBtn);
+
+ expect(save.calls).toHaveLength(1);
+ expect(save.calls[0]?.systemPrompt).toBe(""); // inherit
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+ expect(screen.getByText("Saved.")).toBeInTheDocument();
+ });
+
+ it("editing the task prompt saves + clears unsaved state", async () => {
+ const user = userEvent.setup();
+ const save = fakeSaveConfig();
+ render(PromptEditor, {
+ props: baseProps({ saveConfig: save.impl }),
+ });
+
+ const taskBox = await screen.findByLabelText("Heartbeat task prompt");
+ await user.type(taskBox, "do the thing");
+
+ const saveBtn = screen.getByRole("button", { name: "Save" });
+ expect(saveBtn).toBeEnabled();
+ await user.click(saveBtn);
+
+ expect(save.calls[0]?.taskPrompt).toBe("do the thing");
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+ expect(screen.getByText("Saved.")).toBeInTheDocument();
+ });
+
+ it("a failed save surfaces the error and keeps the edit unsaved", async () => {
+ const user = userEvent.setup();
+ const failingSave: SaveHeartbeatConfig = async () => ({ ok: false, error: "boom" });
+ render(PromptEditor, {
+ props: baseProps({ saveConfig: failingSave }),
+ });
+
+ const systemBox = await screen.findByLabelText("Heartbeat system prompt");
+ await user.clear(systemBox);
+ await user.type(systemBox, "custom");
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ // Still unsaved (Save stays enabled), no success badge.
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
+ });
});