summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat/logic
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/heartbeat/logic')
-rw-r--r--src/features/heartbeat/logic/types.ts10
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts57
-rw-r--r--src/features/heartbeat/logic/view-model.ts9
3 files changed, 76 insertions, 0 deletions
diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts
index 3d3d525..83cec74 100644
--- a/src/features/heartbeat/logic/types.ts
+++ b/src/features/heartbeat/logic/types.ts
@@ -26,6 +26,15 @@ export type HeartbeatRunStatus = "running" | "completed" | "stopped";
export interface HeartbeatConfig {
/** Whether the autonomous loop is enabled (running on the interval). */
readonly enabled: boolean;
+ /**
+ * When true (the default), the heartbeat SKIPS a fire whenever the configured
+ * workspace has any active agents (a conversation whose persisted status is
+ * `"active"` or `"queued"`) — it stays quiet while the user is actively
+ * working and only fires when the workspace is idle. When false, the heartbeat
+ * fires unconditionally on every interval. The heartbeat-spawned conversation
+ * lives in a dedicated workspace, so an in-flight run never self-blocks.
+ */
+ readonly inactiveOnly: boolean;
readonly systemPrompt: string;
readonly taskPrompt: string;
/** Minutes between runs. */
@@ -46,6 +55,7 @@ export interface HeartbeatConfig {
*/
export interface HeartbeatConfigPatch {
readonly enabled?: boolean;
+ readonly inactiveOnly?: boolean;
readonly systemPrompt?: string;
readonly taskPrompt?: string;
readonly intervalMinutes?: number;
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts
index aca0aa6..c9ef118 100644
--- a/src/features/heartbeat/logic/view-model.test.ts
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -39,6 +39,7 @@ const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({
const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({
enabled: false,
+ inactiveOnly: true,
systemPrompt: "be helpful",
taskPrompt: "check status",
intervalMinutes: 15,
@@ -254,6 +255,40 @@ describe("config form", () => {
const f = formFromConfig(c);
expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form
});
+
+ it("emptyForm defaults inactiveOnly to true (on by default)", () => {
+ expect(emptyForm().inactiveOnly).toBe(true);
+ });
+
+ it("formFromConfig carries inactiveOnly through verbatim", () => {
+ expect(formFromConfig(config({ inactiveOnly: true })).inactiveOnly).toBe(true);
+ expect(formFromConfig(config({ inactiveOnly: false })).inactiveOnly).toBe(false);
+ });
+
+ it("formFromConfig coerces a missing/malformed inactiveOnly to the default (true)", () => {
+ // A legacy config (undefined) or a non-boolean is read as ON (true) — matches
+ // normalizeHeartbeatConfig's default and the backend's "on by default".
+ const f = formFromConfig(config({ inactiveOnly: undefined as unknown as boolean }));
+ expect(f.inactiveOnly).toBe(true);
+ });
+
+ it("patchFromForm carries inactiveOnly", () => {
+ expect(patchFromForm(formFromConfig(config({ inactiveOnly: false }))).inactiveOnly).toBe(false);
+ expect(patchFromForm(formFromConfig(config({ inactiveOnly: true }))).inactiveOnly).toBe(true);
+ });
+
+ it("formDiffers is true after toggling inactiveOnly", () => {
+ const c = config({ inactiveOnly: true });
+ const f = formFromConfig(c);
+ f.inactiveOnly = false;
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers is false for a form seeded from the config (inactiveOnly unchanged)", () => {
+ const c = config({ inactiveOnly: false });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false);
+ });
});
describe("system-prompt inheritance (override ⇄ global default)", () => {
@@ -321,6 +356,7 @@ describe("normalizeHeartbeatConfig", () => {
it("passes through a well-formed config", () => {
const c = normalizeHeartbeatConfig({
enabled: true,
+ inactiveOnly: false,
systemPrompt: "sys",
taskPrompt: "task",
intervalMinutes: 20,
@@ -329,6 +365,7 @@ describe("normalizeHeartbeatConfig", () => {
});
expect(c).toEqual({
enabled: true,
+ inactiveOnly: false,
systemPrompt: "sys",
taskPrompt: "task",
intervalMinutes: 20,
@@ -339,10 +376,12 @@ describe("normalizeHeartbeatConfig", () => {
it("coerces a malformed body safely (never throws, never undefined)", () => {
const c = normalizeHeartbeatConfig({
enabled: "yes",
+ inactiveOnly: "yes",
intervalMinutes: -3,
reasoningEffort: "bogus",
});
expect(c.enabled).toBe(false);
+ expect(c.inactiveOnly).toBe(true); // non-boolean → default ON
expect(c.intervalMinutes).toBe(1);
expect(c.reasoningEffort).toBeNull();
expect(c.systemPrompt).toBe("");
@@ -355,12 +394,30 @@ describe("normalizeHeartbeatConfig", () => {
it("handles null / non-object input", () => {
const c = normalizeHeartbeatConfig(null);
expect(c.enabled).toBe(false);
+ expect(c.inactiveOnly).toBe(true); // default ON for an absent config
expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES);
expect(c.model).toBe("");
});
it("clamps a huge interval", () => {
expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440);
});
+ it("inactiveOnly defaults to true when absent (legacy config → on by default)", () => {
+ // A config persisted by an older backend (no inactiveOnly field) reads back
+ // as true — the feature is ON by default for everyone.
+ expect(normalizeHeartbeatConfig({}).inactiveOnly).toBe(true);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: undefined }).inactiveOnly).toBe(true);
+ });
+ it("inactiveOnly passes through an explicit false (opt-out)", () => {
+ expect(normalizeHeartbeatConfig({ inactiveOnly: false }).inactiveOnly).toBe(false);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: true }).inactiveOnly).toBe(true);
+ });
+ it("inactiveOnly treats only an explicit boolean false as false (not 0, not null)", () => {
+ // The wire contract requires a JSON boolean; a non-boolean (0, null, "no")
+ // is treated as the default (true) rather than silently misbehaving.
+ expect(normalizeHeartbeatConfig({ inactiveOnly: 0 }).inactiveOnly).toBe(true);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: null }).inactiveOnly).toBe(true);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: "false" }).inactiveOnly).toBe(true);
+ });
});
describe("normalizeHeartbeatRuns", () => {
diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts
index e91febd..4a5ba7f 100644
--- a/src/features/heartbeat/logic/view-model.ts
+++ b/src/features/heartbeat/logic/view-model.ts
@@ -220,6 +220,7 @@ export function approximateNextRunEpoch(
*/
export interface HeartbeatFormState {
enabled: boolean;
+ inactiveOnly: boolean;
systemPrompt: string;
taskPrompt: string;
intervalHours: number;
@@ -254,6 +255,7 @@ export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState {
const { hours, minutes } = splitInterval(config.intervalMinutes);
return {
enabled: config.enabled === true,
+ inactiveOnly: config.inactiveOnly !== false,
systemPrompt: config.systemPrompt ?? "",
taskPrompt: config.taskPrompt ?? "",
intervalHours: hours,
@@ -268,6 +270,7 @@ export function emptyForm(): HeartbeatFormState {
const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES);
return {
enabled: false,
+ inactiveOnly: true,
systemPrompt: "",
taskPrompt: "",
intervalHours: hours,
@@ -295,6 +298,7 @@ export function normalizeInterval(value: unknown): number {
export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch {
return {
enabled: form.enabled,
+ inactiveOnly: form.inactiveOnly,
systemPrompt: form.systemPrompt,
taskPrompt: form.taskPrompt,
intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes),
@@ -308,6 +312,7 @@ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig):
const { hours, minutes } = splitInterval(config.intervalMinutes);
return (
form.enabled !== config.enabled ||
+ form.inactiveOnly !== config.inactiveOnly ||
form.systemPrompt !== (config.systemPrompt ?? "") ||
form.taskPrompt !== (config.taskPrompt ?? "") ||
form.intervalHours !== hours ||
@@ -390,6 +395,10 @@ export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig {
const effort = d.reasoningEffort;
return {
enabled: d.enabled === true,
+ // Default ON (true): a missing/falsey-but-not-false field (a legacy config
+ // persisted before the field shipped) reads back as inactiveOnly: true —
+ // the feature is on by default for everyone. Only an explicit `false` opts out.
+ inactiveOnly: d.inactiveOnly !== false,
systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "",
taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "",
intervalMinutes: normalizeInterval(d.intervalMinutes),