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
134 changes: 127 additions & 7 deletions assistant/src/home/__tests__/feed-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ mock.module("../../runtime/assistant-event-hub.js", () => ({
const {
HOME_FEED_FILENAME,
HOME_FEED_VERSION,
MAX_ACTIONS_PER_SOURCE,
appendFeedItem,
getHomeFeedPath,
patchFeedItemStatus,
Expand Down Expand Up @@ -273,21 +274,20 @@ describe("feed-writer", () => {
expect(nudges[0]!.title).toBe("Assistant original");
});

test("action without expiresAt auto-fades 24h after createdAt", async () => {
const createdAt = "2026-04-14T12:00:00.000Z";
test("action without expiresAt is persisted with no auto-fade", async () => {
// Action items are the feed's activity log — they must persist
// until the user dismisses them. The writer used to fill in a
// 24h default expiresAt; that behavior is intentionally gone.
Comment on lines +279 to +280

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Test comment narrates removed code history, violating AGENTS.md comment rule

The comment at lines 279-280 says "The writer used to fill in a 24h default expiresAt; that behavior is intentionally gone." This violates the mandatory rule in assistant/AGENTS.md:21: "do not reference code that has been removed. Comments should describe the current state of the codebase, not narrate its history. Avoid phrases like 'no longer does X', 'previously used Y', or 'was removed in PR Z'." The phrases "used to fill in" and "intentionally gone" directly narrate the codebase's history rather than describing its current state.

Suggested change
// until the user dismisses them. The writer used to fill in a
// 24h default expiresAt; that behavior is intentionally gone.
// Action items are the feed's activity log — they persist
// until the user dismisses them or the per-source cap prunes them.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

await appendFeedItem(
makeItem({
id: "action-1",
type: "action",
createdAt,
createdAt: "2026-04-14T12:00:00.000Z",
}),
);
const decoded = readFileJson();
expect(decoded.items).toHaveLength(1);
const item = decoded.items[0]!;
expect(item.expiresAt).toBeDefined();
const expectedMs = Date.parse(createdAt) + 24 * 60 * 60 * 1000;
expect(Date.parse(item.expiresAt!)).toBe(expectedMs);
expect(decoded.items[0]!.expiresAt).toBeUndefined();
});

test("action with an explicit expiresAt is left untouched", async () => {
Expand All @@ -304,6 +304,126 @@ describe("feed-writer", () => {
expect(decoded.items[0]!.expiresAt).toBe(explicit);
});

test("multiple actions with the same (type, source) all persist", async () => {
// Actions must not collapse onto each other by (type, source) —
// each append is a distinct entry in the activity log.
await appendFeedItem(
makeItem({
id: "action-a",
type: "action",
source: "gmail",
title: "Acted A",
createdAt: "2026-04-14T10:00:00.000Z",
}),
);
await appendFeedItem(
makeItem({
id: "action-b",
type: "action",
source: "gmail",
title: "Acted B",
createdAt: "2026-04-14T11:00:00.000Z",
}),
);
await appendFeedItem(
makeItem({
id: "action-c",
type: "action",
source: "gmail",
title: "Acted C",
createdAt: "2026-04-14T12:00:00.000Z",
}),
);

const decoded = readFileJson();
const gmailActions = decoded.items.filter(
(i) => i.type === "action" && i.source === "gmail",
);
expect(gmailActions).toHaveLength(3);
const ids = new Set(gmailActions.map((i) => i.id));
expect(ids).toEqual(new Set(["action-a", "action-b", "action-c"]));
});

test("per-source action cap keeps only the N most recent per source", async () => {
// Append MAX+5 actions for gmail, interleaved with a handful of
// slack actions and a digest. Cap must apply only to the
// overflowing source.
const overflow = MAX_ACTIONS_PER_SOURCE + 5;
for (let i = 0; i < overflow; i++) {
await appendFeedItem(
makeItem({
id: `gmail-${i}`,
type: "action",
source: "gmail",
title: `Gmail action ${i}`,
createdAt: new Date(
Date.parse("2026-04-14T00:00:00.000Z") + i * 60_000,
).toISOString(),
}),
);
}
await appendFeedItem(
makeItem({
id: "slack-1",
type: "action",
source: "slack",
title: "Slack action",
createdAt: "2026-04-14T12:00:00.000Z",
}),
);
await appendFeedItem(
makeItem({
id: "digest-1",
type: "digest",
source: "gmail",
title: "Gmail digest",
createdAt: "2026-04-14T12:01:00.000Z",
}),
);

const decoded = readFileJson();
const gmailActions = decoded.items.filter(
(i) => i.type === "action" && i.source === "gmail",
);
expect(gmailActions).toHaveLength(MAX_ACTIONS_PER_SOURCE);

// The kept ids are the MAX most recent by createdAt — i.e. the
// final MAX entries of the 0..overflow-1 sequence.
const keptIds = new Set(gmailActions.map((i) => i.id));
for (let i = overflow - MAX_ACTIONS_PER_SOURCE; i < overflow; i++) {
expect(keptIds.has(`gmail-${i}`)).toBe(true);
}
for (let i = 0; i < overflow - MAX_ACTIONS_PER_SOURCE; i++) {
expect(keptIds.has(`gmail-${i}`)).toBe(false);
}

// Slack is under the cap and the digest is a different type —
// both untouched by the prune.
expect(decoded.items.filter((i) => i.id === "slack-1")).toHaveLength(1);
expect(decoded.items.filter((i) => i.type === "digest")).toHaveLength(1);
});

test("action items without a source are not subject to the cap", async () => {
const n = MAX_ACTIONS_PER_SOURCE + 3;
for (let i = 0; i < n; i++) {
await appendFeedItem(
makeItem({
id: `sourceless-${i}`,
type: "action",
title: `Sourceless ${i}`,
createdAt: new Date(
Date.parse("2026-04-14T00:00:00.000Z") + i * 60_000,
).toISOString(),
}),
);
}
const decoded = readFileJson();
const sourceless = decoded.items.filter(
(i) => i.type === "action" && i.source === undefined,
);
expect(sourceless).toHaveLength(n);
});

test("thread updates replace the existing thread with the same id in place", async () => {
await appendFeedItem(
makeItem({
Expand Down
6 changes: 3 additions & 3 deletions assistant/src/home/assistant-feed-authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
* the on-disk snapshot via `appendFeedItem`
*
* Persistence is delegated to `appendFeedItem` — all of the merge
* semantics (digest replacement, thread in-place update, author
* precedence, action auto-fade) continue to live in the writer and
* are not re-implemented here.
* semantics (digest replacement, thread in-place update, nudge author
* precedence, action append-without-replace, per-source action cap)
* continue to live in the writer and are not re-implemented here.
*
* NOTE: This helper is intentionally in-process only. There is no
* HTTP route wrapping it. Callers (skills, tools, daemon code) import
Expand Down
128 changes: 89 additions & 39 deletions assistant/src/home/feed-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,20 @@
* hybrid-authoring precedence is `assistant` beats `platform` —
* an assistant item overwrites an existing platform item for the
* same pair, but a platform item never overwrites an existing
* assistant item (no-op).
* - Action auto-fade: when an `action` item is appended without an
* explicit `expiresAt`, the writer fills it in as
* `createdAt + 24h` so stale actions fall off on next read.
* assistant item (no-op). Applies to nudges; actions are exempt
* (see next bullet).
* - Action append-without-replace: `action` items are the feed's
* activity log and never merge by `(type, source)` — each append
* becomes a distinct entry so successive background-job events
* don't collapse onto each other. Callers that want to auto-expire
* an action item must set `expiresAt` explicitly; the writer
* does NOT fill in a default expiry.
* - Per-source action cap: after merge, each source keeps at most
* {@link MAX_ACTIONS_PER_SOURCE} action items (most recent by
* `createdAt`). Older actions for that source are dropped so the
* on-disk file can't balloon as background jobs emit events.
* Action items without a `source` are unbounded and passed
* through untouched.
* - TTL filter on read: `readHomeFeed` drops any item whose
* `expiresAt` is in the past. This is a stateless sweep — the
* writer does not rewrite the file on read, so concurrent reads
Expand Down Expand Up @@ -59,11 +69,13 @@ export const HOME_FEED_FILENAME = "home-feed.json";
export const HOME_FEED_VERSION = 1;

/**
* Action items without an explicit `expiresAt` auto-fade this many
* milliseconds after their `createdAt` timestamp. 24 hours matches the
* TDD default.
* Per-source volume cap for `action` items. When the post-merge item
* list has more than this many action items for a single source, the
* oldest (by `createdAt`) are dropped until the count is back within
* the cap. Other item types are unaffected, and action items without
* a `source` are also unaffected.
*/
const ACTION_AUTO_FADE_MS = 24 * 60 * 60 * 1000;
export const MAX_ACTIONS_PER_SOURCE = 20;

/**
* Canonical path to the home-feed snapshot
Expand Down Expand Up @@ -223,6 +235,8 @@ async function runWrite(): Promise<void> {
items = mergeIncoming(items, incoming);
}

items = pruneActionsPerSource(items);

// Track the per-patch result so callers can distinguish an update
// from an unknown-id no-op. We collect resolvers first and fire them
// after the write lands so the resolved `FeedItem` matches on-disk
Expand Down Expand Up @@ -286,71 +300,107 @@ async function runWrite(): Promise<void> {
* array is not mutated.
*/
function mergeIncoming(items: FeedItem[], incoming: FeedItem): FeedItem[] {
const withDefaults = applyActionAutoFade(incoming);

// Digest replacement: one digest per source wins.
if (withDefaults.type === "digest" && withDefaults.source) {
if (incoming.type === "digest" && incoming.source) {
const filtered = items.filter(
(i) => !(i.type === "digest" && i.source === withDefaults.source),
(i) => !(i.type === "digest" && i.source === incoming.source),
);
filtered.push(withDefaults);
filtered.push(incoming);
return filtered;
}

// Thread in-place update: same id wins, preserve position.
if (withDefaults.type === "thread") {
if (incoming.type === "thread") {
const idx = items.findIndex(
(i) => i.type === "thread" && i.id === withDefaults.id,
(i) => i.type === "thread" && i.id === incoming.id,
);
if (idx !== -1) {
const copy = items.slice();
copy[idx] = withDefaults;
copy[idx] = incoming;
return copy;
}
}

// Action append-without-replace: each action item is a distinct
// activity-log entry and must NOT collapse onto an existing action
// for the same (type, source) pair. The per-source volume cap in
// `pruneActionsPerSource` keeps the log from growing unbounded.
if (incoming.type === "action") {
return [...items, incoming];
}

// Author resolution: for matching (type, source) pairs, assistant
// beats platform. A platform-authored incoming item against an
// existing assistant item is a no-op.
if (withDefaults.source) {
// existing assistant item is a no-op. Applies to nudges (actions
// short-circuit above).
if (incoming.source) {
const existingIdx = items.findIndex(
(i) => i.type === withDefaults.type && i.source === withDefaults.source,
(i) => i.type === incoming.type && i.source === incoming.source,
);
if (existingIdx !== -1) {
const existing = items[existingIdx]!;
if (
existing.author === "assistant" &&
withDefaults.author === "platform"
) {
if (existing.author === "assistant" && incoming.author === "platform") {
// Platform can't overwrite assistant — no-op.
return items;
}
if (
existing.author === "platform" &&
withDefaults.author === "assistant"
) {
if (existing.author === "platform" && incoming.author === "assistant") {
const copy = items.slice();
copy[existingIdx] = withDefaults;
copy[existingIdx] = incoming;
return copy;
}
}
}

return [...items, withDefaults];
return [...items, incoming];
}

/**
* Fill in the `expiresAt` field on action items that were appended
* without one. Pure — returns the original reference untouched when no
* auto-fade is needed so the common path avoids a copy.
* Enforce the per-source volume cap on `action` items. For each
* source that has more than {@link MAX_ACTIONS_PER_SOURCE} actions in
* the post-merge list, keep the most recent by `createdAt` and drop
* the rest. Other item types and action items without a `source` are
* passed through untouched. Stable with respect to non-affected items.
*/
function applyActionAutoFade(item: FeedItem): FeedItem {
if (item.type !== "action") return item;
if (item.expiresAt) return item;
const createdAtMs = Date.parse(item.createdAt);
if (Number.isNaN(createdAtMs)) return item;
const expiresAt = new Date(createdAtMs + ACTION_AUTO_FADE_MS).toISOString();
return { ...item, expiresAt };
function pruneActionsPerSource(items: FeedItem[]): FeedItem[] {
const actionsBySource = new Map<string, FeedItem[]>();
for (const item of items) {
if (item.type !== "action" || !item.source) continue;
const bucket = actionsBySource.get(item.source);
if (bucket) {
bucket.push(item);
} else {
actionsBySource.set(item.source, [item]);
}
}

const overflowing: string[] = [];
for (const [source, bucket] of actionsBySource) {
if (bucket.length > MAX_ACTIONS_PER_SOURCE) overflowing.push(source);
}
if (overflowing.length === 0) return items;

const keepIds = new Set<string>();
for (const source of overflowing) {
const bucket = actionsBySource.get(source)!.slice();
bucket.sort((a, b) => {
const am = Date.parse(a.createdAt);
const bm = Date.parse(b.createdAt);
if (Number.isNaN(am) && Number.isNaN(bm)) return 0;
if (Number.isNaN(am)) return 1;
if (Number.isNaN(bm)) return -1;
return bm - am;
});
for (const item of bucket.slice(0, MAX_ACTIONS_PER_SOURCE)) {
keepIds.add(item.id);
}
}

return items.filter((item) => {
if (item.type !== "action") return true;
if (!item.source) return true;
if (!overflowing.includes(item.source)) return true;
return keepIds.has(item.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain capped actions by unique entry, not shared id

The cap logic keeps overflowed actions via keepIds and later checks keepIds.has(item.id), which means retention is keyed only by id. If a source emits duplicate action IDs (or two overflowing sources reuse the same ID), an older action can be incorrectly preserved because a newer action with the same ID marked it as kept, so that source can stay above MAX_ACTIONS_PER_SOURCE and stale rows remain visible. Track retained rows by a per-entry key (e.g., source + createdAt + id, or item identity/index) instead of id alone.

Useful? React with 👍 / 👎.

});
}

/**
Expand Down
Loading