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
224 changes: 224 additions & 0 deletions assistant/src/__tests__/update-bulletin-job.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { createHash } from "node:crypto";
import { existsSync, rmSync, writeFileSync } from "node:fs";
import {
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";

import { getWorkspacePromptPath } from "../util/platform.js";

// ── In-memory checkpoint store ───────────────────────────────────────
const store = new Map<string, string>();
let setCheckpointCallCount = 0;

mock.module("../memory/checkpoints.js", () => ({
getMemoryCheckpoint: (key: string) => store.get(key) ?? null,
setMemoryCheckpoint: (key: string, value: string) => {
setCheckpointCallCount += 1;
store.set(key, value);
},
}));

// ── Mutable config stub ──────────────────────────────────────────────
const updatesConfig = { enabled: true };

mock.module("../config/loader.js", () => ({
getConfig: () => ({ updates: updatesConfig }),
}));

// ── bootstrapConversation + wakeAgentForOpportunity mocks ────────────
let bootstrapCalls = 0;
let wakeCalls = 0;
let wakeShouldThrow = false;
// A side-effect function invoked during wake. Lets tests simulate the
// agent deleting UPDATES.md while the wake is in flight.
let wakeSideEffect: (() => void) | null = null;

mock.module("../memory/conversation-bootstrap.js", () => ({
bootstrapConversation: (_opts: unknown) => {
bootstrapCalls += 1;
return { id: `conv-${bootstrapCalls}` };
},
}));

mock.module("../runtime/agent-wake.js", () => ({
wakeAgentForOpportunity: async () => {
wakeCalls += 1;
if (wakeSideEffect) {
wakeSideEffect();
}
if (wakeShouldThrow) {
throw new Error("simulated wake failure");
}
return { invoked: true, producedToolCalls: false };
},
}));

const { runUpdateBulletinJobIfNeeded } = await import(
"../prompts/update-bulletin-job.js"
);

const HASH_CHECKPOINT_KEY = "updates:last_processed_hash";
const EMPTY_HASH = "empty";

function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex");
}

const workspacePath = getWorkspacePromptPath("UPDATES.md");

describe("runUpdateBulletinJobIfNeeded", () => {
beforeEach(() => {
store.clear();
setCheckpointCallCount = 0;
bootstrapCalls = 0;
wakeCalls = 0;
wakeShouldThrow = false;
wakeSideEffect = null;
updatesConfig.enabled = true;
if (existsSync(workspacePath)) {
rmSync(workspacePath);
}
});

afterEach(() => {
if (existsSync(workspacePath)) {
rmSync(workspacePath);
}
});

test("config disabled — no bootstrap, no wake, no checkpoint change", async () => {
updatesConfig.enabled = false;
writeFileSync(workspacePath, "## Real content", "utf-8");

await runUpdateBulletinJobIfNeeded();

expect(bootstrapCalls).toBe(0);
expect(wakeCalls).toBe(0);
expect(setCheckpointCallCount).toBe(0);
expect(store.has(HASH_CHECKPOINT_KEY)).toBe(false);
});

test("file missing, stored hash absent — no wake; stored becomes 'empty'", async () => {
expect(existsSync(workspacePath)).toBe(false);
expect(store.has(HASH_CHECKPOINT_KEY)).toBe(false);

await runUpdateBulletinJobIfNeeded();

expect(bootstrapCalls).toBe(0);
expect(wakeCalls).toBe(0);
expect(store.get(HASH_CHECKPOINT_KEY)).toBe(EMPTY_HASH);
});

test("file missing, stored hash already 'empty' — no wake; no checkpoint write", async () => {
store.set(HASH_CHECKPOINT_KEY, EMPTY_HASH);
// Reset the counter to ignore the priming write above.
setCheckpointCallCount = 0;

await runUpdateBulletinJobIfNeeded();

expect(bootstrapCalls).toBe(0);
expect(wakeCalls).toBe(0);
expect(setCheckpointCallCount).toBe(0);
expect(store.get(HASH_CHECKPOINT_KEY)).toBe(EMPTY_HASH);
});

test("file present but whitespace-only — treated as empty; stored hash 'empty'", async () => {
writeFileSync(workspacePath, " \n\n\t\n", "utf-8");

await runUpdateBulletinJobIfNeeded();

expect(bootstrapCalls).toBe(0);
expect(wakeCalls).toBe(0);
expect(store.get(HASH_CHECKPOINT_KEY)).toBe(EMPTY_HASH);
});

test("file present with content, stored hash absent — bootstrap + wake; stored hash is sha256(trimmed)", async () => {
const content = "## Release 1.2.3\n\nNew thing.\n";
writeFileSync(workspacePath, content, "utf-8");

await runUpdateBulletinJobIfNeeded();

expect(bootstrapCalls).toBe(1);
expect(wakeCalls).toBe(1);
expect(store.get(HASH_CHECKPOINT_KEY)).toBe(sha256(content.trim()));
});

test("file present, stored hash matches current — no wake", async () => {
const content = "## Release 1.2.3\n\nSame content.\n";
writeFileSync(workspacePath, content, "utf-8");
store.set(HASH_CHECKPOINT_KEY, sha256(content.trim()));
setCheckpointCallCount = 0;

await runUpdateBulletinJobIfNeeded();

expect(bootstrapCalls).toBe(0);
expect(wakeCalls).toBe(0);
expect(setCheckpointCallCount).toBe(0);
});

test("file present, stored hash differs — wake invoked; stored hash updates", async () => {
const oldContent = "## Old";
const newContent = "## New content v2";
writeFileSync(workspacePath, newContent, "utf-8");
store.set(HASH_CHECKPOINT_KEY, sha256(oldContent));

await runUpdateBulletinJobIfNeeded();

expect(bootstrapCalls).toBe(1);
expect(wakeCalls).toBe(1);
expect(store.get(HASH_CHECKPOINT_KEY)).toBe(sha256(newContent.trim()));
expect(store.get(HASH_CHECKPOINT_KEY)).not.toBe(sha256(oldContent));
});

test("agent deletes file mid-wake — stored hash becomes 'empty'", async () => {
const content = "## Release X\n\nStuff to process.\n";
writeFileSync(workspacePath, content, "utf-8");
wakeSideEffect = () => {
rmSync(workspacePath);
};

await runUpdateBulletinJobIfNeeded();

expect(bootstrapCalls).toBe(1);
expect(wakeCalls).toBe(1);
expect(existsSync(workspacePath)).toBe(false);
expect(store.get(HASH_CHECKPOINT_KEY)).toBe(EMPTY_HASH);
});

test("wake completes but file unchanged — stored hash = hash of content; rerun is a no-op", async () => {
const content = "## Release Y\n\nAgent chose to no-op.\n";
writeFileSync(workspacePath, content, "utf-8");

await runUpdateBulletinJobIfNeeded();

expect(bootstrapCalls).toBe(1);
expect(wakeCalls).toBe(1);
expect(store.get(HASH_CHECKPOINT_KEY)).toBe(sha256(content.trim()));

// Second run — hash matches, so we short-circuit before bootstrap/wake.
await runUpdateBulletinJobIfNeeded();

expect(bootstrapCalls).toBe(1);
expect(wakeCalls).toBe(1);
});

test("wake throws — function does not reject; warning logged", async () => {
const content = "## Release Z";
writeFileSync(workspacePath, content, "utf-8");
wakeShouldThrow = true;

// Must not throw.
await expect(runUpdateBulletinJobIfNeeded()).resolves.toBeUndefined();

expect(bootstrapCalls).toBe(1);
expect(wakeCalls).toBe(1);
// Hash was never updated because the try/catch returned before the
// self-healing step.
expect(store.has(HASH_CHECKPOINT_KEY)).toBe(false);
});
});
1 change: 1 addition & 0 deletions assistant/src/memory/conversation-title-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type TitleOrigin =
| "filing"
| "local"
| "task_submit"
| "updates-bulletin"
| "misc";

export interface TitleContext {
Expand Down
100 changes: 100 additions & 0 deletions assistant/src/prompts/update-bulletin-job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";

import { getConfig } from "../config/loader.js";
import {
getMemoryCheckpoint,
setMemoryCheckpoint,
} from "../memory/checkpoints.js";
import { bootstrapConversation } from "../memory/conversation-bootstrap.js";
import { wakeAgentForOpportunity } from "../runtime/agent-wake.js";
import { getLogger } from "../util/logger.js";
import { getWorkspacePromptPath } from "../util/platform.js";

const log = getLogger("update-bulletin-job");

const HASH_CHECKPOINT_KEY = "updates:last_processed_hash";
const EMPTY_HASH = "empty";
Comment thread
siddseethepalli marked this conversation as resolved.
const UPDATE_BULLETIN_HINT =
"Check ~/.vellum/workspace/UPDATES.md — new release notes are present. Apply any assistant-facing behavior changes (new tools, deprecations, memory updates). If the user would benefit from knowing about a user-facing change, surface it only when the next topic makes it relevant — do not interrupt them with a proactive message. When you're done processing, delete UPDATES.md with `rm ~/.vellum/workspace/UPDATES.md` (already auto-allowed). A silent no-op is preferable to low-signal chatter.";
Comment thread
siddseethepalli marked this conversation as resolved.
Comment thread
siddseethepalli marked this conversation as resolved.

function computeHash(content: string): string {
return createHash("sha256").update(content).digest("hex");
}

function readTrimmedContent(path: string): string | null {
if (!existsSync(path)) return null;
try {
return readFileSync(path, "utf-8").trim();
} catch {
return null;
}
}

/**
* Fire-and-forget background processor for the release-notes bulletin.
*
* If `~/.vellum/workspace/UPDATES.md` has new (unprocessed) content, this
* bootstraps a background conversation and wakes the agent loop with a hint
* pointing at the file. De-duplication uses a sha256 content hash stored in
* the `updates:last_processed_hash` memory checkpoint — an `"empty"` sentinel
* represents a missing/blank file so the job skips the common no-op case.
*
* The function never throws: any error inside the bootstrap/wake flow is
* logged at `warn` and swallowed, so callers can safely invoke it in a
* non-awaited context.
*/
export async function runUpdateBulletinJobIfNeeded(): Promise<void> {
Comment thread
siddseethepalli marked this conversation as resolved.
if (getConfig().updates.enabled === false) {
return;
}

const updatesPath = getWorkspacePromptPath("UPDATES.md");
const trimmed = readTrimmedContent(updatesPath);

if (trimmed === null || trimmed.length === 0) {
const stored = getMemoryCheckpoint(HASH_CHECKPOINT_KEY);
if (stored !== EMPTY_HASH) {
setMemoryCheckpoint(HASH_CHECKPOINT_KEY, EMPTY_HASH);
}
return;
}

const currentHash = computeHash(trimmed);
const stored = getMemoryCheckpoint(HASH_CHECKPOINT_KEY);
if (stored === currentHash) {
return;
}
Comment thread
siddseethepalli marked this conversation as resolved.

try {
const conv = bootstrapConversation({
conversationType: "background",
source: "updates-bulletin",
origin: "updates-bulletin",
systemHint: "Processing release updates",
groupId: "system:background",
});
await wakeAgentForOpportunity({
conversationId: conv.id,
hint: UPDATE_BULLETIN_HINT,
source: "updates-bulletin",
});

// Self-healing: re-read after the wake. If the agent deleted the file
// (or emptied it), store the empty sentinel. Otherwise, store the
// fresh hash so we don't re-wake on the same content if the agent
// chose to no-op.
const afterTrimmed = readTrimmedContent(updatesPath);
if (afterTrimmed === null || afterTrimmed.length === 0) {
setMemoryCheckpoint(HASH_CHECKPOINT_KEY, EMPTY_HASH);
} else {
setMemoryCheckpoint(HASH_CHECKPOINT_KEY, computeHash(afterTrimmed));
}
} catch (err) {
log.warn(
{ err },
"update-bulletin-job: wake flow threw; swallowing so callers can fire-and-forget",
);
return;
}
}
Loading