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
169 changes: 169 additions & 0 deletions assistant/src/home/__tests__/emit-feed-event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";

// Stub the in-process SSE hub so the writer's publish path is a
// no-op in these tests. Must be in place before the writer module is
// imported (directly or transitively) so the dynamic import below
// picks it up.
const publishSpy = mock<(event: unknown) => Promise<void>>(async () => {});

mock.module("../../runtime/assistant-event-hub.js", () => ({
assistantEventHub: {
publish: publishSpy,
subscribe: () => () => {},
},
}));

const { emitFeedEvent } = await import("../emit-feed-event.js");
const { getHomeFeedPath } = await import("../feed-writer.js");

interface OnDiskItem {
id: string;
type: string;
source?: string;
title: string;
summary: string;
priority: number;
status: string;
author: string;
createdAt: string;
expiresAt?: string;
minTimeAway?: number;
}

function readFileJson(): {
version: number;
items: OnDiskItem[];
updatedAt: string;
} {
return JSON.parse(readFileSync(getHomeFeedPath(), "utf-8"));
}

let workspaceDir: string;
let origWorkspaceDir: string | undefined;

beforeEach(() => {
workspaceDir = mkdtempSync(join(tmpdir(), "vellum-emit-"));
origWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
publishSpy.mockClear();
});

afterEach(() => {
if (origWorkspaceDir === undefined) {
delete process.env.VELLUM_WORKSPACE_DIR;
} else {
process.env.VELLUM_WORKSPACE_DIR = origWorkspaceDir;
}
try {
rmSync(workspaceDir, { recursive: true, force: true });
} catch {
// best-effort
}
});

describe("emitFeedEvent", () => {
test("writes an assistant-authored action item with the opinionated defaults", async () => {
const result = await emitFeedEvent({
source: "gmail",
title: "Replied to Alice",
summary: "Sent a reply to Alice's question about lunch.",
});

expect(result.type).toBe("action");
expect(result.author).toBe("assistant");
expect(result.source).toBe("gmail");
expect(result.status).toBe("new");
expect(result.expiresAt).toBeUndefined();
// Default priority: above platform baseline (40), below the
// assistant nudge default (60).
expect(result.priority).toBe(50);

const decoded = readFileJson();
expect(decoded.items).toHaveLength(1);
const persisted = decoded.items[0]!;
expect(persisted.id).toBe(result.id);
expect(persisted.type).toBe("action");
expect(persisted.author).toBe("assistant");
expect(persisted.title).toBe("Replied to Alice");
expect(persisted.expiresAt).toBeUndefined();
});

test("dedupKey produces a deterministic id so repeat emits update in place", async () => {
await emitFeedEvent({
source: "gmail",
title: "Unread from Alice",
summary: "One unread thread from alice@example.com.",
dedupKey: "unread-msg-42",
});
await emitFeedEvent({
source: "gmail",
title: "Unread from Alice (now 2 messages)",
summary: "Two unread threads from alice@example.com.",
dedupKey: "unread-msg-42",
});

const decoded = readFileJson();
expect(decoded.items).toHaveLength(1);
const item = decoded.items[0]!;
expect(item.id).toBe("emit:gmail:unread-msg-42");
expect(item.title).toBe("Unread from Alice (now 2 messages)");
});

test("omitting dedupKey produces a fresh id on every call", async () => {
const a = await emitFeedEvent({
source: "slack",
title: "Sent reply in #general",
summary: "Posted a reply in #general.",
});
const b = await emitFeedEvent({
source: "slack",
title: "Sent reply in #alerts",
summary: "Posted a reply in #alerts.",
});

expect(a.id).not.toBe(b.id);
const decoded = readFileJson();
expect(decoded.items).toHaveLength(2);
});

test("explicit expiresAt is preserved and round-trips to disk", async () => {
const explicit = "2026-04-20T00:00:00.000Z";
await emitFeedEvent({
source: "calendar",
title: "Meeting prep reminder",
summary: "Standup in 30 minutes — agenda is empty.",
expiresAt: explicit,
dedupKey: "standup-prep",
});

const decoded = readFileJson();
expect(decoded.items[0]!.expiresAt).toBe(explicit);
});

test("explicit priority overrides the default", async () => {
await emitFeedEvent({
source: "assistant",
title: "Ran weekly review",
summary: "Consolidated last week's activity into a digest.",
priority: 75,
dedupKey: "weekly-review",
});

const decoded = readFileJson();
expect(decoded.items[0]!.priority).toBe(75);
});

test("out-of-range priority throws a ZodError at the source", async () => {
await expect(
emitFeedEvent({
source: "gmail",
title: "Valid title",
summary: "Valid summary",
priority: 150,
}),
).rejects.toThrow();
});
});
33 changes: 33 additions & 0 deletions assistant/src/home/__tests__/feed-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,39 @@ describe("feed-writer", () => {
expect(decoded.items[0]!.expiresAt).toBe(explicit);
});

test("action with same id updates the existing entry in place", async () => {
// Deterministic-dedup callers (emit-feed-event.ts dedupKey)
// emit the same id on repeat signals; the writer must refresh
// the existing entry rather than append a duplicate, otherwise
// the same event would show up N times until the per-source
// cap trimmed it.
await appendFeedItem(
makeItem({
id: "emit:gmail:unread-msg-42",
type: "action",
source: "gmail",
title: "Unread from Alice",
createdAt: "2026-04-14T10:00:00.000Z",
}),
);
await appendFeedItem(
makeItem({
id: "emit:gmail:unread-msg-42",
type: "action",
source: "gmail",
title: "Unread from Alice (refreshed)",
createdAt: "2026-04-14T12:00:00.000Z",
}),
);

const decoded = readFileJson();
const matching = decoded.items.filter(
(i) => i.id === "emit:gmail:unread-msg-42",
);
expect(matching).toHaveLength(1);
expect(matching[0]!.title).toBe("Unread from Alice (refreshed)");
});

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.
Expand Down
158 changes: 158 additions & 0 deletions assistant/src/home/emit-feed-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Background-job → home feed event helper.
*
* The "force-write, not taught-write" entry point for the activity
* log. Every background job that wants to surface something on the
* Home page calls `emitFeedEvent({ source, title, summary, ... })`
* at the end of its completion path — no LLM involved, no prompt
* instruction, just a deterministic side effect. This keeps the
* "what got surfaced" question grep-able to a single symbol.
*
* Opinionated defaults for action items:
*
* - `type` is hard-coded to `"action"` — this helper is specifically
* for the activity log. Nudges / digests / threads continue to
* go through `writeAssistantFeedItem` or platform baseline
* generators.
* - `author` is hard-coded to `"assistant"` so hybrid authoring
* resolution treats these as assistant-produced (platform
* defaults can never overwrite them).
* - `source` is REQUIRED — actions always have an origin (gmail,
* slack, calendar, assistant). The per-source volume cap in
* `feed-writer.ts` depends on this.
* - No default `expiresAt`. Action items persist until the user
* dismisses them. Callers that want auto-expiry pass `expiresAt`
* explicitly.
* - Optional `dedupKey` — when set, the helper derives a
* deterministic id so a second emit for the same logical event
* (e.g. the same background job running twice on the same
* signal) updates the existing entry in place instead of
* appending a duplicate. When absent, a fresh `randomUUID` is
* used and every call produces a new entry.
*
* Persistence goes through `appendFeedItem`, inheriting its
* warn-log-on-failure contract — callers never need a try/catch.
* Schema validation runs at build time so a malformed call throws
* loudly at the source rather than silently corrupting the file.
*/

import { randomUUID } from "node:crypto";

import {
type FeedAction,
type FeedItem,
feedItemSchema,
type FeedItemSource,
} from "./feed-types.js";
import { appendFeedItem } from "./feed-writer.js";

/**
* Default priority for background-job action items. Sits below the
* assistant nudge default (60) so an explicit nudge from
* `writeAssistantFeedItem` surfaces above routine activity log
* entries, but above the platform baseline (40) so background job
* traces outrank same-source platform defaults.
*/
const DEFAULT_EMIT_PRIORITY = 50;

/**
* Parameters accepted by {@link emitFeedEvent}.
*
* All action items emitted by background jobs have an origin, so
* `source` is required. Everything else is optional — callers supply
* only the fields that describe the specific event.
*/
export interface EmitFeedEventParams {
/** Origin of the underlying event (gmail, slack, calendar, assistant). */
source: FeedItemSource;
/** Short headline rendered in the feed row. */
title: string;
/** Body copy rendered below the title. */
summary: string;
/**
* Stable key used to derive a deterministic id so a second emit
* for the same logical event updates the existing feed entry in
* place. Should include enough structure to identify the event
* uniquely (e.g. `"gmail-unread:msg-<messageId>"`,
* `"task-runner:<taskId>"`). When omitted, every call produces a
* fresh id and appends a new entry.
*/
dedupKey?: string;
/**
* Priority in [0, 100]. Defaults to {@link DEFAULT_EMIT_PRIORITY}
* (50) — above the platform baseline of 40, below the assistant
* nudge default of 60.
*/
priority?: number;
/** Action buttons surfaced on the feed row. */
actions?: FeedAction[];
/** Minimum seconds the user must be away before the item is shown. */
minTimeAway?: number;
/**
* Absolute ISO-8601 expiry timestamp. Omit to let the item persist
* until the user dismisses it (default for activity-log actions).
*/
expiresAt?: string;
}

/**
* Build a deterministic feed item id from a source + dedup key.
*
* The id is intentionally human-readable: `emit:<source>:<dedupKey>`.
* This makes debugging easier than a hash (you can eyeball the file
* and immediately see which background job produced which entry)
* and `FeedItem.id` is a free-form string so there is no length or
* charset constraint to worry about.
*/
function deterministicId(source: FeedItemSource, dedupKey: string): string {
return `emit:${source}:${dedupKey}`;
Comment on lines +107 to +108

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 Sanitize dedupKey before building deterministic IDs

The deterministic ID path uses the raw dedupKey (emit:<source>:<dedupKey>), but feed item IDs are later used as URL path segments (PATCH /v1/home/feed/:id and POST /v1/home/feed/:id/actions/:actionId). If a caller provides a key with reserved path characters (notably /), the item can be written and returned by GET but becomes unreliable to patch/act on because routing splits that segment. Since dedup keys are expected to come from external event identifiers, this should be normalized to a URL-safe form (or hashed/validated) before composing the ID.

Useful? React with 👍 / 👎.

}

/**
* Emit a background-job activity-log entry onto the home feed.
*
* Builds a fully-formed assistant-authored `action` {@link FeedItem},
* validates it against the canonical schema, and persists it via
* {@link appendFeedItem}. Returns the constructed item so the caller
* can log / reference it downstream.
*
* Throws a `ZodError` if the constructed item fails validation
* (e.g. a `priority` outside `[0, 100]`) — a programming error in
* the caller that must not be silently swallowed. Persistence-layer
* failures are absorbed by `appendFeedItem` per its warn-log
* contract.
*/
export async function emitFeedEvent(
params: EmitFeedEventParams,
): Promise<FeedItem> {
const now = new Date().toISOString();

const id =
params.dedupKey !== undefined
? deterministicId(params.source, params.dedupKey)
: randomUUID();

const item: FeedItem = {
id,
type: "action",
source: params.source,
title: params.title,
summary: params.summary,
priority: params.priority ?? DEFAULT_EMIT_PRIORITY,
status: "new",
author: "assistant",
timestamp: now,
createdAt: now,
actions: params.actions,
minTimeAway: params.minTimeAway,
expiresAt: params.expiresAt,
};

// Programming-error guardrail: invalid input throws at the source
// instead of corrupting the on-disk snapshot via the writer.
feedItemSchema.parse(item);

await appendFeedItem(item);

return item;
}
Loading
Loading