@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.
- package/ARCHITECTURE.md +15 -17
- package/README.md +0 -6
- package/package.json +1 -1
- package/src/__tests__/anthropic-provider.test.ts +43 -0
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
- package/src/__tests__/checker.test.ts +6 -12
- package/src/__tests__/config-loader-backfill.test.ts +4 -2
- package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
- package/src/__tests__/config-watcher.test.ts +2 -2
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-title-service.test.ts +2 -1
- package/src/__tests__/disk-pressure-tools.test.ts +1 -1
- package/src/__tests__/filing-service.test.ts +8 -3
- package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
- package/src/__tests__/heartbeat-service.test.ts +5 -2
- package/src/__tests__/injector-chain.test.ts +8 -3
- package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
- package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
- package/src/__tests__/oauth-cli.test.ts +19 -8
- package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
- package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
- package/src/__tests__/system-prompt.test.ts +0 -11
- package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
- package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
- package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
- package/src/background-wake/wake-intent-hooks.test.ts +2 -0
- package/src/config/call-site-defaults.ts +13 -2
- package/src/config/loader.ts +52 -59
- package/src/config/schema.ts +0 -2
- package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
- package/src/config/schemas/__tests__/memory-v3.test.ts +1 -1
- package/src/config/schemas/llm.ts +10 -0
- package/src/config/schemas/memory-v2.ts +13 -0
- package/src/config/schemas/memory-v3.ts +1 -1
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +0 -19
- package/src/daemon/trust-context.ts +1 -1
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
- package/src/home/__tests__/home-content-refresh.test.ts +114 -0
- package/src/home/__tests__/suggested-prompts.test.ts +86 -5
- package/src/home/home-content-refresh.ts +43 -31
- package/src/home/home-greeting-cache.ts +8 -1
- package/src/home/home-greeting.ts +13 -9
- package/src/home/suggested-prompts.ts +77 -24
- package/src/memory/conversation-bootstrap.ts +18 -9
- package/src/memory/conversation-crud.ts +12 -2
- package/src/memory/conversation-title-service.ts +53 -9
- package/src/memory/graph/extraction-job.ts +0 -15
- package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
- package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
- package/src/memory/v2/__tests__/injection.test.ts +70 -0
- package/src/memory/v2/__tests__/static-context.test.ts +12 -0
- package/src/memory/v2/consolidation-job.ts +93 -9
- package/src/memory/v2/injection.ts +53 -0
- package/src/memory/v2/prompts/consolidation.ts +1 -0
- package/src/memory/v2/static-context.ts +13 -1
- package/src/memory/v2/sweep-job.ts +1 -1
- package/src/memory/v2/types.ts +5 -0
- package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
- package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +1 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +1 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +4 -4
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +1 -1
- package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +1 -0
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +41 -7
- package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
- package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
- package/src/providers/anthropic/client.ts +41 -14
- package/src/providers/retry.ts +8 -0
- package/src/providers/types.ts +11 -0
- package/src/runtime/AGENTS.md +5 -1
- package/src/runtime/__tests__/agent-wake.test.ts +2 -2
- package/src/runtime/agent-wake.ts +4 -4
- package/src/runtime/background-job-runner.ts +2 -2
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
- package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
- package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
- package/src/runtime/migrations/vbundle-importer.ts +9 -4
- package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
- package/src/runtime/pre-first-message-gate.ts +1 -1
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
- package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
- package/src/runtime/routes/home-feed-routes.ts +10 -0
- package/src/schedule/scheduler.ts +1 -0
- package/src/util/platform.ts +16 -0
- package/src/watcher/engine.ts +1 -0
- package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
- package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
- package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
- package/src/__tests__/update-bulletin-job.test.ts +0 -292
- package/src/config/schemas/updates.ts +0 -14
- package/src/memory/conversation-starters-cadence.ts +0 -78
- 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
|
-
###
|
|
773
|
+
### On-Demand Home Content Generation
|
|
774
774
|
|
|
775
|
-
|
|
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. **
|
|
780
|
-
2. **
|
|
781
|
-
3. **
|
|
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
|
-
|
|
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
|
|
790
|
-
|
|
|
791
|
-
| `src/
|
|
792
|
-
| `src/
|
|
793
|
-
| `src/
|
|
794
|
-
| `src/
|
|
795
|
-
| `src/
|
|
796
|
-
| `src/
|
|
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
|
@@ -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
|
-
|
|
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
|
|
432
|
-
const
|
|
433
|
-
const result = await check("file_read", { path:
|
|
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
|
|
444
|
-
const
|
|
445
|
-
const result = await check("file_edit", { path:
|
|
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
|
-
|
|
329
|
-
|
|
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("
|
|
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, "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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 →
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
+
});
|