diff options
Diffstat (limited to 'src/features/heartbeat/logic')
| -rw-r--r-- | src/features/heartbeat/logic/types.ts | 10 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.test.ts | 57 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.ts | 9 |
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), |
