@vellumai/assistant 0.8.11-dev.202606120321.552e02e → 0.8.11-dev.202606120751.ef8e25a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/package.json +1 -1
  4. package/src/__tests__/anthropic-provider.test.ts +43 -0
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  6. package/src/__tests__/checker.test.ts +6 -12
  7. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  8. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  9. package/src/__tests__/config-watcher.test.ts +2 -2
  10. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  11. package/src/__tests__/conversation-title-service.test.ts +2 -1
  12. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  13. package/src/__tests__/filing-service.test.ts +8 -3
  14. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  15. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  16. package/src/__tests__/heartbeat-service.test.ts +5 -2
  17. package/src/__tests__/injector-chain.test.ts +8 -3
  18. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  19. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  20. package/src/__tests__/oauth-cli.test.ts +19 -8
  21. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  22. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  23. package/src/__tests__/system-prompt.test.ts +0 -11
  24. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  25. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  26. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  27. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  28. package/src/config/call-site-defaults.ts +13 -2
  29. package/src/config/loader.ts +52 -59
  30. package/src/config/schema.ts +0 -2
  31. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  32. package/src/config/schemas/__tests__/memory-v3.test.ts +1 -1
  33. package/src/config/schemas/llm.ts +10 -0
  34. package/src/config/schemas/memory-v2.ts +13 -0
  35. package/src/config/schemas/memory-v3.ts +1 -1
  36. package/src/daemon/disk-pressure-policy.ts +0 -1
  37. package/src/daemon/lifecycle.ts +0 -19
  38. package/src/daemon/trust-context.ts +1 -1
  39. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  40. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  41. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  42. package/src/home/home-content-refresh.ts +43 -31
  43. package/src/home/home-greeting-cache.ts +8 -1
  44. package/src/home/home-greeting.ts +13 -9
  45. package/src/home/suggested-prompts.ts +77 -24
  46. package/src/memory/conversation-bootstrap.ts +18 -9
  47. package/src/memory/conversation-crud.ts +12 -2
  48. package/src/memory/conversation-title-service.ts +53 -9
  49. package/src/memory/graph/extraction-job.ts +0 -15
  50. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  51. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  52. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  53. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  54. package/src/memory/v2/consolidation-job.ts +93 -9
  55. package/src/memory/v2/injection.ts +53 -0
  56. package/src/memory/v2/prompts/consolidation.ts +1 -0
  57. package/src/memory/v2/static-context.ts +13 -1
  58. package/src/memory/v2/sweep-job.ts +1 -1
  59. package/src/memory/v2/types.ts +5 -0
  60. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  61. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  62. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +1 -1
  63. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +1 -1
  64. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +4 -4
  65. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  66. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +1 -1
  67. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  68. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +1 -0
  69. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  70. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +41 -7
  71. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  72. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  73. package/src/providers/anthropic/client.ts +41 -14
  74. package/src/providers/retry.ts +8 -0
  75. package/src/providers/types.ts +11 -0
  76. package/src/runtime/AGENTS.md +5 -1
  77. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  78. package/src/runtime/agent-wake.ts +4 -4
  79. package/src/runtime/background-job-runner.ts +2 -2
  80. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  81. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  82. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  83. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  84. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  85. package/src/runtime/pre-first-message-gate.ts +1 -1
  86. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  87. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  88. package/src/runtime/routes/home-feed-routes.ts +10 -0
  89. package/src/schedule/scheduler.ts +1 -0
  90. package/src/util/platform.ts +16 -0
  91. package/src/watcher/engine.ts +1 -0
  92. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  93. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  94. package/src/workspace/migrations/registry.ts +4 -0
  95. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  96. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  97. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  98. package/src/config/schemas/updates.ts +0 -14
  99. package/src/memory/conversation-starters-cadence.ts +0 -78
  100. package/src/prompts/update-bulletin-job.ts +0 -180
package/ARCHITECTURE.md CHANGED
@@ -770,30 +770,28 @@ The macOS `SpeechRecognizerAdapter` protocol in `clients/macos/vellum-assistant/
770
770
  - Terminology: "STT" and "transcription" refer to the same operation (converting audio to text). "Speech recognition" is used in client-native contexts where Apple's Speech framework terminology is canonical. All three terms map to the same conceptual operation.
771
771
  - **Onboarding**: For a step-by-step guide to adding a new STT provider, see `docs/stt-provider-onboarding.md`.
772
772
 
773
- ### Update Bulletin System
773
+ ### On-Demand Home Content Generation
774
774
 
775
- Release-driven update notification system that dispatches a background conversation to process release notes when a release lands.
775
+ LLM-generated content shown by clients (personalized home greeting, suggested prompts, conversation starters, identity intro) is produced on demand, never at daemon startup or on unconditional timers.
776
776
 
777
- **Data flow:**
777
+ **Data flow (home greeting + suggested prompts):**
778
778
 
779
- 1. **Storage** — Release notes live at `<workspace>/UPDATES.md`. The file is written by workspace migrations; each release that needs to surface notes ships a dedicated migration in `src/workspace/migrations/` that appends a release-notes block to the file. The workspace-migration runner is the authoritative idempotency mechanism: `runWorkspaceMigrations()` records each migration's `WorkspaceMigration.id` in `<workspace>/data/.workspace-migrations.json` and never re-runs an ID that is already in the `applied` set.
780
- 2. **Dispatch** — At daemon startup (after `runWorkspaceMigrations()`), `runUpdateBulletinJobIfNeeded()` is invoked fire-and-forget. It hashes the current `UPDATES.md` content and compares against the `updates:last_processed_hash` checkpoint. When the hashes differ, it bootstraps a `conversationType: "background"` conversation and calls `wakeAgentForOpportunity()` so the agent processes the bulletin without any interactive session.
781
- 3. **Completion** — The agent acts on the contents and deletes `UPDATES.md` when done. The job persists the new hash to `updates:last_processed_hash` post-wake, so subsequent startups short-circuit until the file is repopulated by a future migration.
779
+ 1. **Read** — `GET /v1/home/feed` reads both caches synchronously: the greeting from `memory_checkpoints` (`home:greeting:*`, 4-hour TTL, busted when identity files change) and suggested prompts from `memory_checkpoints` (`home:suggested_prompts:*`, 4-hour TTL, invalidated on OAuth connect/disconnect). When a cache is empty the handler falls back to a time-of-day greeting and an empty prompt list.
780
+ 2. **Revalidate** — The same GET fires `revalidateHomeContentInBackground()` (`src/home/home-content-refresh.ts`) fire-and-forget. It is single-flight (concurrent GETs share one run) and each refresher no-ops while its cache is fresh, so generation happens at most once per TTL window — and only when a user actually views Home.
781
+ 3. **Notify** — When fresh content lands, the coordinator publishes a `home_feed_updated` event; connected clients refetch the feed and the personalized content swaps in.
782
782
 
783
- **Checkpoint keys** (in `memory_checkpoints` table):
784
-
785
- - `updates:last_processed_hash` — content hash of the `UPDATES.md` payload most recently dispatched to the background job.
783
+ Conversation starters follow the same pattern via `GET /v1/conversation-starters`, which enqueues a `generate_conversation_starters` memory job when the starter set is stale (cooldown-gated, deduped against in-flight jobs).
786
784
 
787
785
  **Key source files:**
788
786
 
789
- | File | Purpose |
790
- | -------------------------------------- | ----------------------------------------------------------------------------------------- |
791
- | `src/workspace/migrations/` | Per-release migrations that append release notes to `UPDATES.md` |
792
- | `src/workspace/migrations/registry.ts` | Append-only `WORKSPACE_MIGRATIONS` registry |
793
- | `src/prompts/update-bulletin-job.ts` | `runUpdateBulletinJobIfNeeded()` — hash check, background dispatch, and checkpoint update |
794
- | `src/daemon/lifecycle.ts` | Fire-and-forget dispatch of `runUpdateBulletinJobIfNeeded()` after DB init at startup |
795
- | `src/config/schemas/updates.ts` | `updates.enabled` config toggle (defaults to `true`; disables the background dispatch) |
796
- | `src/permissions/defaults.ts` | Auto-allow rules for file_read/write/edit + bare-filename `rm UPDATES.md` |
787
+ | File | Purpose |
788
+ | --------------------------------------------------- | ------------------------------------------------------------------------ |
789
+ | `src/home/home-content-refresh.ts` | Single-flight on-demand revalidation coordinator + SSE notify |
790
+ | `src/home/home-greeting.ts` | Greeting generation (BTW side-chain, `homeGreeting` call site) |
791
+ | `src/home/home-greeting-cache.ts` | Checkpoint-backed greeting cache with identity-hash invalidation |
792
+ | `src/home/suggested-prompts.ts` | Prompt generation + checkpoint-backed cache + OAuth invalidation |
793
+ | `src/runtime/routes/home-feed-routes.ts` | `GET /v1/home/feed` — cached read + fire-and-forget revalidation trigger |
794
+ | `src/runtime/routes/conversation-starter-routes.ts` | On-demand starter refresh via memory job enqueue |
797
795
 
798
796
  ---
799
797
 
package/README.md CHANGED
@@ -45,12 +45,6 @@ cp .env.example .env
45
45
  | `RUNTIME_HTTP_PORT` | No | — | Enable the HTTP server (required for gateway/web) |
46
46
  | `RUNTIME_HTTP_HOST` | No | `127.0.0.1` | HTTP server bind address |
47
47
 
48
- ## Update Bulletin
49
-
50
- Release notes are surfaced via a background conversation dispatched at daemon startup. Workspace migrations write release notes to `<workspace>/UPDATES.md`; `runUpdateBulletinJobIfNeeded()` then spawns a `conversationType: "background"` conversation via `runBackgroundJob()` (see `runtime/background-job-runner.ts`) whenever the file's content hash changes. The agent uses judgment to surface updates to the user when relevant, and deletes the file when done.
51
-
52
- **For release maintainers:** Add a new migration under `assistant/src/workspace/migrations/0XX-release-notes-<slug>.ts` with the release notes inline as a string literal, and append the export to `WORKSPACE_MIGRATIONS` in `assistant/src/workspace/migrations/registry.ts`. Migrations are append-only. Idempotency requires both the workspace-migration runner AND an in-file marker: `runWorkspaceMigrations()` records each migration's `WorkspaceMigration.id` in `<workspace>/data/.workspace-migrations.json` and skips IDs already in the `applied` set, but a crash between `UPDATES.md` append and checkpoint finalize can cause a duplicate append on next boot. Embed an HTML marker like `<!-- release-note-id:<migration-id> -->` in the appended block, and short-circuit when the marker is already present. See the root `AGENTS.md` "Release Update Hygiene" section for the full rationale. Skip the migration entirely for releases with no user/assistant-facing changes.
53
-
54
48
  ## Usage
55
49
 
56
50
  ### Lifecycle management (recommended)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.11-dev.202606120321.552e02e",
3
+ "version": "0.8.11-dev.202606120751.ef8e25a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -389,6 +389,49 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
389
389
  ).not.toContain("extended-cache-ttl-2025-04-11");
390
390
  });
391
391
 
392
+ test("disableCache sends no cache_control anywhere and strips caller-stamped block markers", async () => {
393
+ // A one-shot call site that opted out of prompt caching: no breakpoint
394
+ // on system, tools, turn-start, or tail — and a caller-stamped block
395
+ // marker is removed rather than forwarded.
396
+ const messages: Message[] = [
397
+ {
398
+ role: "user",
399
+ content: [
400
+ cachedPrefixBlock("stable pages block"),
401
+ { type: "text", text: "Do something" },
402
+ ],
403
+ },
404
+ toolUseMsg("tu_1", "bash"),
405
+ toolResultMsg("tu_1", "output"),
406
+ ];
407
+ await provider.sendMessage(messages, {
408
+ tools: sampleTools,
409
+ systemPrompt: "You are a helpful assistant.",
410
+ config: { disableCache: true },
411
+ });
412
+
413
+ const system = lastStreamParams!.system as Array<{
414
+ cache_control?: unknown;
415
+ }>;
416
+ expect(system[0].cache_control).toBeUndefined();
417
+
418
+ const tools = lastStreamParams!.tools as Array<{
419
+ cache_control?: unknown;
420
+ }>;
421
+ for (const tool of tools) {
422
+ expect(tool.cache_control).toBeUndefined();
423
+ }
424
+
425
+ const sent = lastStreamParams!.messages as Array<{
426
+ content: Array<{ cache_control?: unknown }>;
427
+ }>;
428
+ for (const message of sent) {
429
+ for (const block of message.content) {
430
+ expect(block.cache_control).toBeUndefined();
431
+ }
432
+ }
433
+ });
434
+
392
435
  test("v3-shape call (system + tools + cached prefix block) stays within the 4-breakpoint cap", async () => {
393
436
  // Mirrors a v3 L2 selector call: a system prompt, a forced tool, and a user
394
437
  // message whose stable <pages> block carries a cache_control breakpoint
@@ -52,7 +52,7 @@ mock.module("../config/loader.js", () => ({
52
52
  getNestedValue: () => undefined,
53
53
  setNestedValue: () => {},
54
54
  API_KEY_PROVIDERS: [],
55
- _appendQuarantineBulletin: () => {},
55
+ _writeQuarantineNotice: () => {},
56
56
  invalidateConfigCache: () => {},
57
57
  }));
58
58
 
@@ -158,6 +158,9 @@ mock.module("../memory/conversation-crud.js", () => ({
158
158
 
159
159
  mock.module("../memory/conversation-title-service.js", () => ({
160
160
  GENERATING_TITLE: "Generating title...",
161
+ AUTO_TITLE_DETERMINISTIC: 2,
162
+ deriveDeterministicTitle: (context: { systemHint?: string }) =>
163
+ context.systemHint ?? "Untitled Conversation",
161
164
  isReplaceableTitle: () => true,
162
165
  queueGenerateConversationTitle: () => {},
163
166
  queueRegenerateConversationTitle: () => {},
@@ -428,21 +428,15 @@ describe("Permission Checker", () => {
428
428
  expect(result.decision).toBe("allow");
429
429
  });
430
430
 
431
- test("file_read of workspace UPDATES.md is auto-allowed", async () => {
432
- const updatesPath = join(checkerTestDir, "UPDATES.md");
433
- const result = await check("file_read", { path: updatesPath }, "/tmp");
434
- expect(result.decision).toBe("allow");
435
- });
436
-
437
- test("file_write of workspace UPDATES.md is auto-allowed", async () => {
438
- const updatesPath = join(checkerTestDir, "UPDATES.md");
439
- const result = await check("file_write", { path: updatesPath }, "/tmp");
431
+ test("file_read of workspace BOOTSTRAP.md is auto-allowed", async () => {
432
+ const bootstrapPath = join(checkerTestDir, "BOOTSTRAP.md");
433
+ const result = await check("file_read", { path: bootstrapPath }, "/tmp");
440
434
  expect(result.decision).toBe("allow");
441
435
  });
442
436
 
443
- test("file_edit of workspace UPDATES.md is auto-allowed", async () => {
444
- const updatesPath = join(checkerTestDir, "UPDATES.md");
445
- const result = await check("file_edit", { path: updatesPath }, "/tmp");
437
+ test("file_edit of workspace BOOTSTRAP.md is auto-allowed", async () => {
438
+ const bootstrapPath = join(checkerTestDir, "BOOTSTRAP.md");
439
+ const result = await check("file_edit", { path: bootstrapPath }, "/tmp");
446
440
  expect(result.decision).toBe("allow");
447
441
  });
448
442
 
@@ -82,6 +82,7 @@ import { migrateProviderConnectionStatusLabel } from "../memory/migrations/244-p
82
82
  import { migrateProviderConnectionBaseUrlAndModels } from "../memory/migrations/250-provider-connection-base-url-and-models.js";
83
83
  import * as schema from "../memory/schema.js";
84
84
  import { getConnection } from "../providers/inference/connections.js";
85
+ import { getConfigQuarantineNoticePath } from "../util/platform.js";
85
86
  import { setStorePathForTesting } from "./encrypted-store-test-helpers.js";
86
87
 
87
88
  // ---------------------------------------------------------------------------
@@ -325,8 +326,9 @@ describe("loadConfig startup behavior", () => {
325
326
  }
326
327
  }
327
328
  }
328
- const updatesPath = join(WORKSPACE_DIR, "UPDATES.md");
329
- if (existsSync(updatesPath)) rmSync(updatesPath, { force: true });
329
+ // Clear any leftover config-quarantine notice sentinel from prior runs.
330
+ const noticePath = getConfigQuarantineNoticePath();
331
+ if (existsSync(noticePath)) rmSync(noticePath, { force: true });
330
332
  ensureTestDir();
331
333
  setStorePathForTesting(join(WORKSPACE_DIR, "keys.enc"));
332
334
  delete process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * When loadConfig()/loadRawConfig() quarantines a corrupt config.json, it
3
+ * writes a small JSON sentinel to <workspace>/data/config-quarantine-notice.json
4
+ * recording the event. The per-turn `config-quarantine-notice` injector reads
5
+ * that sentinel and surfaces the reset to the agent — it's agent-visible
6
+ * context, not a push notification.
7
+ *
8
+ * The sentinel is overwritten on each quarantine (idempotent per event) and
9
+ * resolves from a workspace-derived path so it is safe to write during the
10
+ * very early config load, before the SQLite DB or getConfig().dataDir exist.
11
+ */
12
+
13
+ import {
14
+ existsSync,
15
+ mkdirSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ rmSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
23
+
24
+ import {
25
+ _writeQuarantineNotice,
26
+ invalidateConfigCache,
27
+ loadConfig,
28
+ } from "../config/loader.js";
29
+ import { getConfigQuarantineNoticePath } from "../util/platform.js";
30
+ import { setStorePathForTesting } from "./encrypted-store-test-helpers.js";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const WORKSPACE_DIR = process.env.VELLUM_WORKSPACE_DIR!;
37
+ const CONFIG_PATH = join(WORKSPACE_DIR, "config.json");
38
+ const NOTICE_PATH = getConfigQuarantineNoticePath();
39
+
40
+ function ensureTestDir(): void {
41
+ const dirs = [
42
+ WORKSPACE_DIR,
43
+ join(WORKSPACE_DIR, "data"),
44
+ join(WORKSPACE_DIR, "data", "memory"),
45
+ join(WORKSPACE_DIR, "data", "memory", "knowledge"),
46
+ join(WORKSPACE_DIR, "data", "logs"),
47
+ ];
48
+ for (const dir of dirs) {
49
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
50
+ }
51
+ }
52
+
53
+ function resetWorkspace(): void {
54
+ for (const name of readdirSync(WORKSPACE_DIR)) {
55
+ rmSync(join(WORKSPACE_DIR, name), { recursive: true, force: true });
56
+ }
57
+ ensureTestDir();
58
+ }
59
+
60
+ function listQuarantinedFiles(): string[] {
61
+ return readdirSync(WORKSPACE_DIR).filter((name) =>
62
+ /^config\.json\.corrupt-.+\.json$/.test(name),
63
+ );
64
+ }
65
+
66
+ function readNotice(): {
67
+ quarantinedAt: string;
68
+ quarantinePath: string;
69
+ originalPath: string;
70
+ } {
71
+ return JSON.parse(readFileSync(NOTICE_PATH, "utf-8"));
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Tests
76
+ // ---------------------------------------------------------------------------
77
+
78
+ describe("config-quarantine notice sentinel", () => {
79
+ beforeEach(() => {
80
+ resetWorkspace();
81
+ setStorePathForTesting(join(WORKSPACE_DIR, "keys.enc"));
82
+ invalidateConfigCache();
83
+ });
84
+
85
+ afterEach(() => {
86
+ setStorePathForTesting(null);
87
+ invalidateConfigCache();
88
+ });
89
+
90
+ test("writes a sentinel recording the quarantine when config.json is corrupt", () => {
91
+ writeFileSync(CONFIG_PATH, '{"provider": "anthropic", "mo');
92
+
93
+ loadConfig();
94
+
95
+ const [quarantinedName] = listQuarantinedFiles();
96
+ expect(quarantinedName).toBeDefined();
97
+ const quarantinedPath = join(WORKSPACE_DIR, quarantinedName);
98
+
99
+ expect(existsSync(NOTICE_PATH)).toBe(true);
100
+ const notice = readNotice();
101
+ expect(notice.quarantinePath).toBe(quarantinedPath);
102
+ expect(notice.originalPath).toBe(CONFIG_PATH);
103
+ // quarantinedAt is a parseable ISO timestamp.
104
+ expect(Number.isNaN(Date.parse(notice.quarantinedAt))).toBe(false);
105
+ });
106
+
107
+ test("a second quarantine overwrites the sentinel with the latest event", () => {
108
+ // First corruption round.
109
+ writeFileSync(CONFIG_PATH, '{"partial": ');
110
+ loadConfig();
111
+ invalidateConfigCache();
112
+
113
+ const firstNotice = readNotice();
114
+ expect(listQuarantinedFiles()).toHaveLength(1);
115
+
116
+ // Loader wrote a fresh default config.json after quarantine; corrupt it
117
+ // again. Spin briefly so the second quarantine gets a distinct
118
+ // millisecond-precision filename (and a later timestamp).
119
+ const untilDifferentMs = Date.now() + 5;
120
+ while (Date.now() < untilDifferentMs) {
121
+ /* spin */
122
+ }
123
+ writeFileSync(CONFIG_PATH, "still not json");
124
+ loadConfig();
125
+
126
+ const quarantined = listQuarantinedFiles().sort();
127
+ expect(quarantined).toHaveLength(2);
128
+
129
+ // Single sentinel, pointing at the most recent quarantine.
130
+ const secondNotice = readNotice();
131
+ expect(secondNotice.quarantinePath).not.toBe(firstNotice.quarantinePath);
132
+ expect(secondNotice.quarantinePath).toBe(
133
+ join(WORKSPACE_DIR, quarantined[quarantined.length - 1]),
134
+ );
135
+ });
136
+
137
+ test("valid config.json does not create a sentinel (regression guard)", () => {
138
+ writeFileSync(
139
+ CONFIG_PATH,
140
+ JSON.stringify({ provider: "anthropic", model: "claude-opus-4-7" }),
141
+ );
142
+
143
+ loadConfig();
144
+
145
+ expect(listQuarantinedFiles()).toHaveLength(0);
146
+ expect(existsSync(NOTICE_PATH)).toBe(false);
147
+ });
148
+
149
+ test("sentinel write failure is swallowed (startup never blocks)", () => {
150
+ // Make the notice directory unwritable by replacing it with a file so the
151
+ // write/mkdir path throws. The helper must not propagate the error.
152
+ const noticeDir = join(WORKSPACE_DIR, "data");
153
+ rmSync(noticeDir, { recursive: true, force: true });
154
+ // A regular file where the `data` directory is expected makes mkdir/write
155
+ // fail with ENOTDIR/EEXIST.
156
+ writeFileSync(noticeDir, "not a directory", "utf-8");
157
+
158
+ const quarantinePath = join(
159
+ WORKSPACE_DIR,
160
+ "config.json.corrupt-2026-04-20T12-00-00.000Z.json",
161
+ );
162
+
163
+ expect(() =>
164
+ _writeQuarantineNotice(CONFIG_PATH, quarantinePath),
165
+ ).not.toThrow();
166
+ });
167
+ });
@@ -266,12 +266,12 @@ describe("ConfigWatcher workspace file handlers", () => {
266
266
  expect(identityCallCount).toBe(1);
267
267
  });
268
268
 
269
- test("UPDATES.md is not subscribed (only the registered handler set is)", () => {
269
+ test("unregistered workspace files are not subscribed (only the registered handler set is)", () => {
270
270
  watcher.start(onConversationEvict);
271
271
  // Per-file watching only registers config.json, SOUL.md, IDENTITY.md.
272
272
  // The whole workspace dir must not be watched either — that was the
273
273
  // ENXIO-on-Unix-sockets bug.
274
- expect(findFileWatch(join(WORKSPACE_DIR, "UPDATES.md"))).toBeUndefined();
274
+ expect(findFileWatch(join(WORKSPACE_DIR, "OTHER.md"))).toBeUndefined();
275
275
  expect(findWatcher(WORKSPACE_DIR)).toBeUndefined();
276
276
  });
277
277
 
@@ -3,7 +3,7 @@
3
3
  * `inferenceProfile` column at turn start and threads it through to
4
4
  * `AgentLoop.run()` as the per-turn `overrideProfile`. Background
5
5
  * conversations intentionally skip the column so background fan-out
6
- * (subagents, scheduled tasks, update bulletins) runs on the workspace
6
+ * (subagents, scheduled tasks) runs on the workspace
7
7
  * defaults rather than inheriting an interactive override.
8
8
  *
9
9
  * This is the "conversation-agent-loop integration" half of the
@@ -59,6 +59,7 @@ mock.module("../runtime/sync/resource-sync-events.js", () => ({
59
59
  }));
60
60
 
61
61
  import {
62
+ AUTO_TITLE_DETERMINISTIC,
62
63
  generateAndPersistConversationTitle,
63
64
  queueGenerateConversationTitle,
64
65
  regenerateConversationTitle,
@@ -260,7 +261,7 @@ describe("conversation-title-service", () => {
260
261
  expect(mockUpdateConversationTitle).toHaveBeenCalledWith(
261
262
  "conv-1",
262
263
  "Untitled Conversation",
263
- 1,
264
+ AUTO_TITLE_DETERMINISTIC,
264
265
  );
265
266
  });
266
267
 
@@ -41,7 +41,7 @@ mock.module("../config/loader.js", () => ({
41
41
  setNestedValue: () => undefined,
42
42
  mergeDefaultWorkspaceConfig: (config: unknown) => config,
43
43
  API_KEY_PROVIDERS: [] as const,
44
- _appendQuarantineBulletin: () => undefined,
44
+ _writeQuarantineNotice: () => undefined,
45
45
  }));
46
46
 
47
47
  mock.module("../daemon/conversation-skill-tools.js", () => ({
@@ -96,9 +96,14 @@ mock.module("../util/logger.js", () => ({
96
96
  }),
97
97
  }));
98
98
 
99
- // Mock conversation title service
99
+ // Mock conversation title service. `deriveDeterministicTitle` mirrors the
100
+ // real implementation's systemHint passthrough so bootstrap-created
101
+ // conversations carry their job hint as the title.
100
102
  mock.module("../memory/conversation-title-service.js", () => ({
101
103
  GENERATING_TITLE: "Generating title...",
104
+ AUTO_TITLE_DETERMINISTIC: 2,
105
+ deriveDeterministicTitle: (context: { systemHint?: string }) =>
106
+ context.systemHint ?? "Untitled Conversation",
102
107
  queueGenerateConversationTitle: () => {},
103
108
  }));
104
109
 
@@ -230,12 +235,12 @@ describe("FilingService", () => {
230
235
  );
231
236
  });
232
237
 
233
- test("creates background conversation with generating title placeholder", async () => {
238
+ test("creates background conversation with the deterministic filing title", async () => {
234
239
  const service = createService();
235
240
  await service.runOnce();
236
241
 
237
242
  expect(createdConversations).toHaveLength(1);
238
- expect(createdConversations[0].title).toBe("Generating title...");
243
+ expect(createdConversations[0].title).toBe("Knowledge base filing");
239
244
  expect(createdConversations[0].conversationType).toBe("background");
240
245
  // Confirms FilingService routes through runBackgroundJob:
241
246
  // source="filing" + runner-default groupId="system:background".
@@ -65,7 +65,7 @@ mock.module("../config/loader.js", () => ({
65
65
  saveRawConfig: () => {},
66
66
  getNestedValue: () => undefined,
67
67
  setNestedValue: () => {},
68
- _appendQuarantineBulletin: () => {},
68
+ _writeQuarantineNotice: () => {},
69
69
  }));
70
70
 
71
71
  mock.module("../config/skill-state.js", () => ({
@@ -37,7 +37,7 @@ mock.module("../config/loader.js", () => ({
37
37
  getNestedValue: () => undefined,
38
38
  setNestedValue: () => {},
39
39
  API_KEY_PROVIDERS: [],
40
- _appendQuarantineBulletin: () => {},
40
+ _writeQuarantineNotice: () => {},
41
41
  invalidateConfigCache: () => {},
42
42
  }));
43
43
 
@@ -102,6 +102,9 @@ mock.module("../prompts/persona-resolver.js", () => ({
102
102
 
103
103
  mock.module("../memory/conversation-title-service.js", () => ({
104
104
  GENERATING_TITLE: "Generating title...",
105
+ AUTO_TITLE_DETERMINISTIC: 2,
106
+ deriveDeterministicTitle: (context: { systemHint?: string }) =>
107
+ context.systemHint ?? "Untitled Conversation",
105
108
  queueGenerateConversationTitle: () => {},
106
109
  }));
107
110
 
@@ -250,6 +250,9 @@ mock.module("../notifications/emit-signal.js", () => ({
250
250
  // Mock conversation title service
251
251
  mock.module("../memory/conversation-title-service.js", () => ({
252
252
  GENERATING_TITLE: "Generating title...",
253
+ AUTO_TITLE_DETERMINISTIC: 2,
254
+ deriveDeterministicTitle: (context: { systemHint?: string }) =>
255
+ context.systemHint ?? "Untitled Conversation",
253
256
  queueGenerateConversationTitle: () => {},
254
257
  }));
255
258
 
@@ -497,12 +500,12 @@ describe("HeartbeatService", () => {
497
500
  expect(processMessageCalls[0].content).toContain("Check in with yourself");
498
501
  });
499
502
 
500
- test("creates background conversation with generating title placeholder", async () => {
503
+ test("creates background conversation with the deterministic heartbeat title", async () => {
501
504
  const service = createService();
502
505
  await service.runOnce();
503
506
 
504
507
  expect(createdConversations).toHaveLength(1);
505
- expect(createdConversations[0].title).toBe("Generating title...");
508
+ expect(createdConversations[0].title).toBe("Heartbeat");
506
509
  expect(createdConversations[0].conversationType).toBe("background");
507
510
  });
508
511
 
@@ -5,9 +5,10 @@
5
5
  *
6
6
  * 1. The default injectors ({@link defaultInjectors}) are listed in the
7
7
  * documented order (disk-pressure-warning → workspace-context →
8
- * background-turn → unified-turn-context → pkb-context → pkb-reminder →
9
- * memory-v2-static → now-md → active-documents → document-comments →
10
- * subagent-status → slack-messages → thread-focus).
8
+ * background-turn → unified-turn-context → config-quarantine-notice →
9
+ * pkb-context → pkb-reminder → memory-v2-static → now-md →
10
+ * active-documents → document-comments → subagent-status →
11
+ * slack-messages → thread-focus).
11
12
  * 2. The assembled {@link injectorChain} sorts the defaults together with the
12
13
  * memory-v3 injector by ascending `order`, so memory-v3 (order 1000) lands
13
14
  * last.
@@ -298,6 +299,7 @@ describe("injector chain", () => {
298
299
  "workspace-context",
299
300
  "background-turn",
300
301
  "unified-turn-context",
302
+ "config-quarantine-notice",
301
303
  "pkb-context",
302
304
  "pkb-reminder",
303
305
  "memory-v2-static",
@@ -324,6 +326,9 @@ describe("injector chain", () => {
324
326
  expect(byName.get("unified-turn-context")).toBe(
325
327
  DEFAULT_INJECTOR_ORDER.unifiedTurnContext,
326
328
  );
329
+ expect(byName.get("config-quarantine-notice")).toBe(
330
+ DEFAULT_INJECTOR_ORDER.configQuarantineNotice,
331
+ );
327
332
  expect(byName.get("pkb-context")).toBe(DEFAULT_INJECTOR_ORDER.pkbContext);
328
333
  expect(byName.get("pkb-reminder")).toBe(DEFAULT_INJECTOR_ORDER.pkbReminder);
329
334
  expect(byName.get("memory-v2-static")).toBe(
@@ -0,0 +1,115 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+
5
+ import {
6
+ DEFAULT_INJECTOR_ORDER,
7
+ defaultInjectors,
8
+ } from "../plugins/defaults/memory-retrieval/injectors.js";
9
+ import type { Injector, TurnContext } from "../plugins/types.js";
10
+ import { getConfigQuarantineNoticePath } from "../util/platform.js";
11
+
12
+ function findInjector(name: string): Injector {
13
+ const injector = defaultInjectors.find(
14
+ (candidate) => candidate.name === name,
15
+ );
16
+ if (!injector) {
17
+ throw new Error(`injector '${name}' not registered`);
18
+ }
19
+ return injector;
20
+ }
21
+
22
+ function makeContext(overrides: Partial<TurnContext> = {}): TurnContext {
23
+ return {
24
+ requestId: "req-test",
25
+ conversationId: "conv-test",
26
+ turnIndex: 0,
27
+ trust: { sourceChannel: "vellum", trustClass: "guardian" },
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ const NOTICE_PATH = getConfigQuarantineNoticePath();
33
+
34
+ function writeNotice(quarantinedAt: string): void {
35
+ const dir = dirname(NOTICE_PATH);
36
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
+ writeFileSync(
38
+ NOTICE_PATH,
39
+ JSON.stringify(
40
+ {
41
+ quarantinedAt,
42
+ quarantinePath: "/ws/config.json.corrupt-x.json",
43
+ originalPath: "/ws/config.json",
44
+ },
45
+ null,
46
+ 2,
47
+ ),
48
+ "utf-8",
49
+ );
50
+ }
51
+
52
+ function clearNotice(): void {
53
+ rmSync(NOTICE_PATH, { force: true });
54
+ }
55
+
56
+ const injector = findInjector("config-quarantine-notice");
57
+
58
+ describe("config-quarantine-notice injector", () => {
59
+ beforeEach(() => {
60
+ clearNotice();
61
+ });
62
+
63
+ afterEach(() => {
64
+ clearNotice();
65
+ });
66
+
67
+ test("registered at the expected order with prepend-user-tail placement", async () => {
68
+ expect(injector.order).toBe(DEFAULT_INJECTOR_ORDER.configQuarantineNotice);
69
+ writeNotice(new Date().toISOString());
70
+ const block = await injector.produce(makeContext());
71
+ expect(block?.placement).toBe("prepend-user-tail");
72
+ });
73
+
74
+ test("returns null when no sentinel is present", async () => {
75
+ const block = await injector.produce(makeContext());
76
+ expect(block).toBeNull();
77
+ });
78
+
79
+ test("injects a config-reset block when the sentinel is present and fresh", async () => {
80
+ writeNotice(new Date().toISOString());
81
+
82
+ const block = await injector.produce(makeContext());
83
+ expect(block).not.toBeNull();
84
+ expect(block?.id).toBe("config-quarantine-notice");
85
+ expect(block?.text).toContain("<config_reset_notice>");
86
+ expect(block?.text).toContain("</config_reset_notice>");
87
+ expect(block?.text).toContain("/ws/config.json.corrupt-x.json");
88
+ // The sentinel is left in place for subsequent turns until it ages out.
89
+ expect(existsSync(NOTICE_PATH)).toBe(true);
90
+ });
91
+
92
+ test("injects nothing on non-guardian turns even when the sentinel is fresh", async () => {
93
+ writeNotice(new Date().toISOString());
94
+
95
+ for (const trustClass of ["trusted_contact", "unknown"] as const) {
96
+ const block = await injector.produce(
97
+ makeContext({ trust: { sourceChannel: "slack", trustClass } }),
98
+ );
99
+ expect(block).toBeNull();
100
+ }
101
+ // The sentinel is untouched — it remains for guardian turns.
102
+ expect(existsSync(NOTICE_PATH)).toBe(true);
103
+ });
104
+
105
+ test("deletes the sentinel and injects nothing when it is stale (>7 days)", async () => {
106
+ const eightDaysAgo = new Date(
107
+ Date.now() - 8 * 24 * 60 * 60 * 1000,
108
+ ).toISOString();
109
+ writeNotice(eightDaysAgo);
110
+
111
+ const block = await injector.produce(makeContext());
112
+ expect(block).toBeNull();
113
+ expect(existsSync(NOTICE_PATH)).toBe(false);
114
+ });
115
+ });