Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 50 additions & 30 deletions assistant/src/home/__tests__/feed-scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,35 @@
* Producer implementations are injected via `FeedSchedulerOptions`
* spies so the tests never touch `mock.module` (which leaks across
* files in Bun's test runner). The dedicated producer tests
* (`reflection-producer.test.ts`, `platform-gmail-digest.test.ts`)
* cover each producer's internal behavior.
* (`rollup-producer.test.ts`, `platform-gmail-digest.test.ts`) cover
* each producer's internal behavior.
*/

import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";

import { startFeedScheduler } from "../feed-scheduler.js";
import type { FeedItem } from "../feed-types.js";
import type { ReflectionResult } from "../reflection-producer.js";
import type { RollupResult } from "../rollup-producer.js";

const gmailDigestRunner = mock<
(now: Date, countSource: () => Promise<number>) => Promise<FeedItem | null>
>(async () => null);

const reflectionRunner = mock<(now: Date) => Promise<ReflectionResult>>(
async () => ({ wroteCount: 0, skippedReason: "empty_items" }),
);
const rollupRunner = mock<(now: Date) => Promise<RollupResult>>(async () => ({
wroteCount: 0,
skippedReason: "empty_items",
}));

const defaultOptions = () => ({
gmailCountSource: async () => 0,
gmailDigestRunner,
reflectionRunner,
rollupRunner,
runOnStart: false,
});

beforeEach(() => {
gmailDigestRunner.mockClear();
reflectionRunner.mockClear();
rollupRunner.mockClear();
});

describe("startFeedScheduler", () => {
Expand All @@ -47,9 +48,9 @@ describe("startFeedScheduler", () => {
const summary = await handle.runOnce(new Date("2026-04-14T12:00:00.000Z"));

expect(summary.gmailDigestRan).toBe(true);
expect(summary.reflectionRan).toBe(true);
expect(summary.rollupRan).toBe(true);
expect(gmailDigestRunner).toHaveBeenCalledTimes(1);
expect(reflectionRunner).toHaveBeenCalledTimes(1);
expect(rollupRunner).toHaveBeenCalledTimes(1);
});

test("gmail digest re-runs every tick once its interval has elapsed", async () => {
Expand All @@ -69,7 +70,7 @@ describe("startFeedScheduler", () => {
expect(summary2.gmailDigestRan).toBe(true);
});

test("reflection only re-runs every 30 minutes", async () => {
test("rollup only re-runs every 30 minutes", async () => {
handle = startFeedScheduler(defaultOptions());

const t0 = new Date("2026-04-14T12:00:00.000Z");
Expand All @@ -78,61 +79,80 @@ describe("startFeedScheduler", () => {
// 5 min later — below the 30-min reflection gate.
const t1 = new Date("2026-04-14T12:05:00.000Z");
const summary1 = await handle.runOnce(t1);
expect(summary1.reflectionRan).toBe(false);
expect(summary1.rollupRan).toBe(false);

// 31 min later — past the 30-min gate, should re-run.
const t2 = new Date("2026-04-14T12:31:00.000Z");
const summary2 = await handle.runOnce(t2);
expect(summary2.reflectionRan).toBe(true);
expect(summary2.rollupRan).toBe(true);
});

test("reflection cooldown is NOT advanced on no_provider so the next tick retries", async () => {
test("rollup cooldown is NOT advanced on no_provider so the next tick retries", async () => {
// Mimic the daemon startup ordering: the scheduler boots before
// the provider registry is ready. The first tick gets no_provider;
// the next tick (even one second later) must still run the
// reflection instead of waiting 30 minutes.
reflectionRunner.mockImplementationOnce(async () => ({
// the next tick (even one second later) must still run the rollup
// instead of waiting 30 minutes.
rollupRunner.mockImplementationOnce(async () => ({
wroteCount: 0,
skippedReason: "no_provider",
}));

handle = startFeedScheduler(defaultOptions());
const t0 = new Date("2026-04-14T12:00:00.000Z");
await handle.runOnce(t0);
expect(reflectionRunner).toHaveBeenCalledTimes(1);
expect(rollupRunner).toHaveBeenCalledTimes(1);

// One second later — providers have initialized.
const t1 = new Date("2026-04-14T12:00:01.000Z");
const summary = await handle.runOnce(t1);

expect(summary.reflectionRan).toBe(true);
expect(reflectionRunner).toHaveBeenCalledTimes(2);
expect(summary.rollupRan).toBe(true);
expect(rollupRunner).toHaveBeenCalledTimes(2);
});

test("reflection cooldown IS advanced on other skip reasons to preserve backoff", async () => {
// empty_items / malformed_output / provider_error are real attempts
// — the next tick should be gated by the full 30-minute window so
// a broken producer doesn't get hammered every tick.
reflectionRunner.mockImplementationOnce(async () => ({
test("rollup cooldown is NOT advanced on no_actions so the next tick retries", async () => {
// no_actions means the activity log was empty — no LLM call was
// made. A subsequent tick should retry as soon as new actions
// land, not wait the full 30-minute window.
rollupRunner.mockImplementationOnce(async () => ({
wroteCount: 0,
skippedReason: "no_actions",
}));

handle = startFeedScheduler(defaultOptions());
await handle.runOnce(new Date("2026-04-14T12:00:00.000Z"));
expect(rollupRunner).toHaveBeenCalledTimes(1);

// One second later — the next tick must still invoke the rollup.
const summary = await handle.runOnce(new Date("2026-04-14T12:00:01.000Z"));
expect(summary.rollupRan).toBe(true);
expect(rollupRunner).toHaveBeenCalledTimes(2);
});

test("rollup cooldown IS advanced on other skip reasons to preserve backoff", async () => {
// empty_items / malformed_output / provider_error are real LLM
// attempts — the next tick should be gated by the full 30-minute
// window so a broken producer doesn't get hammered every tick.
rollupRunner.mockImplementationOnce(async () => ({
wroteCount: 0,
skippedReason: "malformed_output",
}));

handle = startFeedScheduler(defaultOptions());
await handle.runOnce(new Date("2026-04-14T12:00:00.000Z"));
expect(reflectionRunner).toHaveBeenCalledTimes(1);
expect(rollupRunner).toHaveBeenCalledTimes(1);

// Ten minutes later — below the 30-min gate, should NOT re-run.
const summary = await handle.runOnce(new Date("2026-04-14T12:10:00.000Z"));
expect(summary.reflectionRan).toBe(false);
expect(reflectionRunner).toHaveBeenCalledTimes(1);
expect(summary.rollupRan).toBe(false);
expect(rollupRunner).toHaveBeenCalledTimes(1);
});

test("producer exceptions do not break the tick loop", async () => {
gmailDigestRunner.mockImplementationOnce(async () => {
throw new Error("boom");
});
reflectionRunner.mockImplementationOnce(async () => {
rollupRunner.mockImplementationOnce(async () => {
throw new Error("also boom");
});

Expand All @@ -144,7 +164,7 @@ describe("startFeedScheduler", () => {
// the intended behavior — a broken producer shouldn't cause the
// scheduler to hammer it every tick via a backoff bypass.
expect(summary.gmailDigestRan).toBe(true);
expect(summary.reflectionRan).toBe(true);
expect(summary.rollupRan).toBe(true);
});

test("stop() makes subsequent runOnce calls no-op", async () => {
Expand Down
Loading
Loading