-
Notifications
You must be signed in to change notification settings - Fork 89
feat(home-feed): emitFeedEvent helper + same-id action in-place [JARVIS-512] #25586
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`; | ||
| } | ||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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/:idandPOST /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 👍 / 👎.