@vellumai/assistant 0.5.2 → 0.5.4
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 +109 -0
- package/docs/architecture/memory.md +105 -0
- package/docs/skills.md +100 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
- package/src/__tests__/conversation-agent-loop.test.ts +7 -0
- package/src/__tests__/conversation-clear-safety.test.ts +259 -0
- package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
- package/src/__tests__/conversation-wipe.test.ts +226 -0
- package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
- package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
- package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
- package/src/__tests__/inline-command-runner.test.ts +311 -0
- package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
- package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
- package/src/__tests__/list-messages-attachments.test.ts +96 -0
- package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
- package/src/__tests__/memory-brief-time.test.ts +285 -0
- package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
- package/src/__tests__/memory-chunk-archive.test.ts +400 -0
- package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
- package/src/__tests__/memory-episode-archive.test.ts +370 -0
- package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
- package/src/__tests__/memory-observation-archive.test.ts +375 -0
- package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
- package/src/__tests__/memory-recall-quality.test.ts +2 -2
- package/src/__tests__/memory-reducer-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-store.test.ts +728 -0
- package/src/__tests__/memory-reducer-types.test.ts +707 -0
- package/src/__tests__/memory-reducer.test.ts +704 -0
- package/src/__tests__/memory-regressions.test.ts +30 -8
- package/src/__tests__/memory-simplified-config.test.ts +281 -0
- package/src/__tests__/parse-identity-fields.test.ts +129 -0
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
- package/src/__tests__/skill-load-inline-command.test.ts +598 -0
- package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
- package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
- package/src/__tests__/skills-transitive-hash.test.ts +333 -0
- package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
- package/src/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/raw-config-utils.ts +28 -0
- package/src/config/schema.ts +12 -0
- package/src/config/schemas/memory-simplified.ts +101 -0
- package/src/config/schemas/memory.ts +4 -0
- package/src/config/skills.ts +50 -4
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
- package/src/daemon/conversation-agent-loop.ts +71 -1
- package/src/daemon/conversation-lifecycle.ts +11 -1
- package/src/daemon/conversation-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +3 -1
- package/src/daemon/conversation-surfaces.ts +31 -8
- package/src/daemon/conversation.ts +40 -23
- package/src/daemon/handlers/config-embeddings.ts +10 -2
- package/src/daemon/handlers/config-model.ts +0 -9
- package/src/daemon/handlers/conversations.ts +11 -0
- package/src/daemon/handlers/identity.ts +12 -1
- package/src/daemon/lifecycle.ts +52 -1
- package/src/daemon/message-types/conversations.ts +0 -1
- package/src/daemon/server.ts +1 -1
- package/src/followups/followup-store.ts +47 -1
- package/src/memory/archive-recall.ts +516 -0
- package/src/memory/archive-store.ts +400 -0
- package/src/memory/brief-formatting.ts +33 -0
- package/src/memory/brief-open-loops.ts +266 -0
- package/src/memory/brief-time.ts +162 -0
- package/src/memory/brief.ts +75 -0
- package/src/memory/conversation-crud.ts +455 -101
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +16 -0
- package/src/memory/indexer.ts +106 -15
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +9 -3
- package/src/memory/job-handlers/embedding.test.ts +1 -0
- package/src/memory/job-handlers/embedding.ts +83 -0
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +8 -0
- package/src/memory/jobs-worker.ts +20 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
- package/src/memory/migrations/185-memory-brief-state.ts +52 -0
- package/src/memory/migrations/186-memory-archive.ts +109 -0
- package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
- package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/qdrant-client.ts +23 -4
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-store.ts +271 -0
- package/src/memory/reducer-types.ts +106 -0
- package/src/memory/reducer.ts +467 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/index.ts +2 -0
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/memory/schema/memory-archive.ts +121 -0
- package/src/memory/schema/memory-brief.ts +55 -0
- package/src/memory/search/semantic.ts +17 -4
- package/src/oauth/oauth-store.ts +3 -1
- package/src/permissions/checker.ts +89 -6
- package/src/permissions/defaults.ts +14 -0
- package/src/runtime/auth/route-policy.ts +10 -1
- package/src/runtime/routes/conversation-management-routes.ts +94 -2
- package/src/runtime/routes/conversation-query-routes.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +52 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- package/src/runtime/routes/identity-routes.ts +2 -35
- package/src/runtime/routes/llm-context-normalization.ts +14 -1
- package/src/runtime/routes/memory-item-routes.ts +90 -5
- package/src/runtime/routes/secret-routes.ts +3 -0
- package/src/runtime/routes/surface-action-routes.ts +68 -1
- package/src/schedule/schedule-store.ts +28 -0
- package/src/schedule/scheduler.ts +6 -2
- package/src/skills/inline-command-expansions.ts +204 -0
- package/src/skills/inline-command-render.ts +127 -0
- package/src/skills/inline-command-runner.ts +242 -0
- package/src/skills/transitive-version-hash.ts +88 -0
- package/src/tasks/task-store.ts +43 -1
- package/src/telemetry/usage-telemetry-reporter.ts +1 -1
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/permission-checker.ts +8 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
- package/src/tools/skills/load.ts +140 -6
- package/src/util/platform.ts +18 -0
- package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
- package/src/workspace/migrations/registry.ts +1 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safety tests for DELETE /v1/conversations (clear all conversations).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Route policy requires `settings.write` scope (not just `chat.write`)
|
|
6
|
+
* - Missing X-Confirm-Destructive header returns 400 with explanatory message
|
|
7
|
+
* - Wrong header value returns 400
|
|
8
|
+
* - Correct scope + header clears data and returns 204
|
|
9
|
+
* - lifecycle_events contains `conversations_clear_all` audit entry after clear
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
16
|
+
|
|
17
|
+
const testDir = mkdtempSync(join(tmpdir(), "conv-clear-safety-test-"));
|
|
18
|
+
|
|
19
|
+
mock.module("../util/platform.js", () => ({
|
|
20
|
+
getRootDir: () => testDir,
|
|
21
|
+
getDataDir: () => testDir,
|
|
22
|
+
isMacOS: () => process.platform === "darwin",
|
|
23
|
+
isLinux: () => process.platform === "linux",
|
|
24
|
+
isWindows: () => process.platform === "win32",
|
|
25
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
26
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
27
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
28
|
+
ensureDataDir: () => {},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
mock.module("../util/logger.js", () => ({
|
|
32
|
+
getLogger: () =>
|
|
33
|
+
new Proxy({} as Record<string, unknown>, {
|
|
34
|
+
get: () => () => {},
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Auth is NOT disabled — we need enforcePolicy to actually check scopes.
|
|
39
|
+
let authDisabled = false;
|
|
40
|
+
mock.module("../config/env.js", () => ({
|
|
41
|
+
isHttpAuthDisabled: () => authDisabled,
|
|
42
|
+
hasUngatedHttpAuthDisabled: () => false,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
addMessage,
|
|
47
|
+
clearAll,
|
|
48
|
+
createConversation,
|
|
49
|
+
getConversation,
|
|
50
|
+
} from "../memory/conversation-crud.js";
|
|
51
|
+
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
52
|
+
import { enforcePolicy, getPolicy } from "../runtime/auth/route-policy.js";
|
|
53
|
+
import type { AuthContext, Scope } from "../runtime/auth/types.js";
|
|
54
|
+
import { conversationManagementRouteDefinitions } from "../runtime/routes/conversation-management-routes.js";
|
|
55
|
+
|
|
56
|
+
initializeDb();
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
resetDb();
|
|
60
|
+
try {
|
|
61
|
+
rmSync(testDir, { recursive: true });
|
|
62
|
+
} catch {
|
|
63
|
+
/* best effort */
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** Build a synthetic AuthContext for testing. */
|
|
68
|
+
function buildAuthContext(overrides?: {
|
|
69
|
+
principalType?: AuthContext["principalType"];
|
|
70
|
+
scopes?: Scope[];
|
|
71
|
+
}): AuthContext {
|
|
72
|
+
return {
|
|
73
|
+
subject: "actor:self:test-principal",
|
|
74
|
+
principalType: overrides?.principalType ?? "actor",
|
|
75
|
+
assistantId: "self",
|
|
76
|
+
actorPrincipalId: "test-principal",
|
|
77
|
+
scopeProfile: "actor_client_v1",
|
|
78
|
+
scopes: new Set(
|
|
79
|
+
overrides?.scopes ?? [
|
|
80
|
+
"chat.read",
|
|
81
|
+
"chat.write",
|
|
82
|
+
"approval.read",
|
|
83
|
+
"approval.write",
|
|
84
|
+
],
|
|
85
|
+
),
|
|
86
|
+
policyEpoch: 1,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Route policy tests (scope check — enforcePolicy level)
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe("DELETE /v1/conversations — route policy", () => {
|
|
95
|
+
test("conversations/clear-all requires settings.write scope", () => {
|
|
96
|
+
authDisabled = false;
|
|
97
|
+
const policy = getPolicy("conversations/clear-all");
|
|
98
|
+
expect(policy).toBeDefined();
|
|
99
|
+
expect(policy!.requiredScopes).toContain("settings.write");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("chat.write-only token is rejected with 403 for clear-all", () => {
|
|
103
|
+
authDisabled = false;
|
|
104
|
+
const ctx = buildAuthContext({
|
|
105
|
+
scopes: ["chat.read", "chat.write"],
|
|
106
|
+
});
|
|
107
|
+
const result = enforcePolicy("conversations/clear-all", ctx);
|
|
108
|
+
expect(result).not.toBeNull();
|
|
109
|
+
expect(result!.status).toBe(403);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("settings.write token is allowed through clear-all policy", () => {
|
|
113
|
+
authDisabled = false;
|
|
114
|
+
const ctx = buildAuthContext({
|
|
115
|
+
scopes: ["settings.write"],
|
|
116
|
+
});
|
|
117
|
+
const result = enforcePolicy("conversations/clear-all", ctx);
|
|
118
|
+
expect(result).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("single-conversation DELETE (conversations:DELETE) only requires chat.write", () => {
|
|
122
|
+
authDisabled = false;
|
|
123
|
+
const policy = getPolicy("conversations:DELETE");
|
|
124
|
+
expect(policy).toBeDefined();
|
|
125
|
+
expect(policy!.requiredScopes).toContain("chat.write");
|
|
126
|
+
expect(policy!.requiredScopes).not.toContain("settings.write");
|
|
127
|
+
|
|
128
|
+
// A chat.write token should pass the single-conversation delete policy
|
|
129
|
+
const ctx = buildAuthContext({
|
|
130
|
+
scopes: ["chat.read", "chat.write"],
|
|
131
|
+
});
|
|
132
|
+
const result = enforcePolicy("conversations:DELETE", ctx);
|
|
133
|
+
expect(result).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Route handler tests (header check + happy path)
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
describe("DELETE /v1/conversations — route handler", () => {
|
|
142
|
+
/** Get the DELETE conversations handler from the route definitions. */
|
|
143
|
+
function getDeleteHandler() {
|
|
144
|
+
let clearCalled = false;
|
|
145
|
+
const routes = conversationManagementRouteDefinitions({
|
|
146
|
+
switchConversation: async () => null,
|
|
147
|
+
renameConversation: () => true,
|
|
148
|
+
clearAllConversations: () => {
|
|
149
|
+
clearCalled = true;
|
|
150
|
+
return clearAll().conversations;
|
|
151
|
+
},
|
|
152
|
+
cancelGeneration: () => true,
|
|
153
|
+
destroyConversation: () => {},
|
|
154
|
+
undoLastMessage: async () => null,
|
|
155
|
+
regenerateResponse: async () => null,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const deleteRoute = routes.find(
|
|
159
|
+
(r) => r.endpoint === "conversations" && r.method === "DELETE",
|
|
160
|
+
);
|
|
161
|
+
if (!deleteRoute) throw new Error("DELETE conversations route not found");
|
|
162
|
+
return { handler: deleteRoute.handler, wasClearCalled: () => clearCalled };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test("missing X-Confirm-Destructive header returns 400 with explanatory message", async () => {
|
|
166
|
+
const { handler } = getDeleteHandler();
|
|
167
|
+
const req = new Request("http://localhost/v1/conversations", {
|
|
168
|
+
method: "DELETE",
|
|
169
|
+
});
|
|
170
|
+
const response = await handler({
|
|
171
|
+
req,
|
|
172
|
+
url: new URL(req.url),
|
|
173
|
+
server: {} as never,
|
|
174
|
+
authContext: buildAuthContext({ scopes: ["settings.write"] }),
|
|
175
|
+
params: {},
|
|
176
|
+
});
|
|
177
|
+
expect(response.status).toBe(400);
|
|
178
|
+
const body = (await response.json()) as {
|
|
179
|
+
error: { code: string; message: string };
|
|
180
|
+
};
|
|
181
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
182
|
+
expect(body.error.message).toContain("X-Confirm-Destructive");
|
|
183
|
+
expect(body.error.message).toContain("clear-all-conversations");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("wrong X-Confirm-Destructive header value returns 400", async () => {
|
|
187
|
+
const { handler } = getDeleteHandler();
|
|
188
|
+
const req = new Request("http://localhost/v1/conversations", {
|
|
189
|
+
method: "DELETE",
|
|
190
|
+
headers: { "X-Confirm-Destructive": "wrong-value" },
|
|
191
|
+
});
|
|
192
|
+
const response = await handler({
|
|
193
|
+
req,
|
|
194
|
+
url: new URL(req.url),
|
|
195
|
+
server: {} as never,
|
|
196
|
+
authContext: buildAuthContext({ scopes: ["settings.write"] }),
|
|
197
|
+
params: {},
|
|
198
|
+
});
|
|
199
|
+
expect(response.status).toBe(400);
|
|
200
|
+
const body = (await response.json()) as {
|
|
201
|
+
error: { code: string; message: string };
|
|
202
|
+
};
|
|
203
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("correct scope + header clears data and returns 204", async () => {
|
|
207
|
+
// Seed a conversation so we can verify it gets cleared
|
|
208
|
+
const conv = createConversation("safety-test-conv");
|
|
209
|
+
await addMessage(conv.id, "user", "hello from safety test");
|
|
210
|
+
expect(getConversation(conv.id)).not.toBeNull();
|
|
211
|
+
|
|
212
|
+
const { handler, wasClearCalled } = getDeleteHandler();
|
|
213
|
+
const req = new Request("http://localhost/v1/conversations", {
|
|
214
|
+
method: "DELETE",
|
|
215
|
+
headers: { "X-Confirm-Destructive": "clear-all-conversations" },
|
|
216
|
+
});
|
|
217
|
+
const response = await handler({
|
|
218
|
+
req,
|
|
219
|
+
url: new URL(req.url),
|
|
220
|
+
server: {} as never,
|
|
221
|
+
authContext: buildAuthContext({ scopes: ["settings.write"] }),
|
|
222
|
+
params: {},
|
|
223
|
+
});
|
|
224
|
+
expect(response.status).toBe(204);
|
|
225
|
+
expect(wasClearCalled()).toBe(true);
|
|
226
|
+
|
|
227
|
+
// Conversation should be gone
|
|
228
|
+
expect(getConversation(conv.id)).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("lifecycle_events contains conversations_clear_all after successful clear", async () => {
|
|
232
|
+
const { handler } = getDeleteHandler();
|
|
233
|
+
const req = new Request("http://localhost/v1/conversations", {
|
|
234
|
+
method: "DELETE",
|
|
235
|
+
headers: { "X-Confirm-Destructive": "clear-all-conversations" },
|
|
236
|
+
});
|
|
237
|
+
await handler({
|
|
238
|
+
req,
|
|
239
|
+
url: new URL(req.url),
|
|
240
|
+
server: {} as never,
|
|
241
|
+
authContext: buildAuthContext({ scopes: ["settings.write"] }),
|
|
242
|
+
params: {},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Query lifecycle_events table directly
|
|
246
|
+
const raw = (
|
|
247
|
+
getDb() as unknown as {
|
|
248
|
+
$client: import("bun:sqlite").Database;
|
|
249
|
+
}
|
|
250
|
+
).$client;
|
|
251
|
+
const rows = raw
|
|
252
|
+
.query(
|
|
253
|
+
"SELECT event_name FROM lifecycle_events WHERE event_name = 'conversations_clear_all'",
|
|
254
|
+
)
|
|
255
|
+
.all() as Array<{ event_name: string }>;
|
|
256
|
+
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
257
|
+
expect(rows[0].event_name).toBe("conversations_clear_all");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
const testDir = mkdtempSync(join(tmpdir(), "conv-dirty-tail-test-"));
|
|
7
|
+
|
|
8
|
+
mock.module("../util/platform.js", () => ({
|
|
9
|
+
getDataDir: () => testDir,
|
|
10
|
+
isMacOS: () => process.platform === "darwin",
|
|
11
|
+
isLinux: () => process.platform === "linux",
|
|
12
|
+
isWindows: () => process.platform === "win32",
|
|
13
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
14
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
15
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
16
|
+
ensureDataDir: () => {},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
mock.module("../util/logger.js", () => ({
|
|
20
|
+
getLogger: () =>
|
|
21
|
+
new Proxy({} as Record<string, unknown>, {
|
|
22
|
+
get: () => () => {},
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
addMessage,
|
|
28
|
+
createConversation,
|
|
29
|
+
getConversation,
|
|
30
|
+
getMessages,
|
|
31
|
+
markConversationMemoryDirty,
|
|
32
|
+
} from "../memory/conversation-crud.js";
|
|
33
|
+
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
34
|
+
|
|
35
|
+
initializeDb();
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
resetDb();
|
|
39
|
+
try {
|
|
40
|
+
rmSync(testDir, { recursive: true });
|
|
41
|
+
} catch {
|
|
42
|
+
/* best effort */
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("markConversationMemoryDirty", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
const db = getDb();
|
|
49
|
+
db.run(`DELETE FROM messages`);
|
|
50
|
+
db.run(`DELETE FROM conversations`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("first message marks the conversation dirty with its message ID", async () => {
|
|
54
|
+
const conv = createConversation("test");
|
|
55
|
+
const msg = await addMessage(conv.id, "user", "hello world", undefined, {
|
|
56
|
+
skipIndexing: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const updated = getConversation(conv.id);
|
|
60
|
+
expect(updated).not.toBeNull();
|
|
61
|
+
expect(updated!.memoryDirtyTailSinceMessageId).toBe(msg.id);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("repeated messages preserve the original dirty boundary", async () => {
|
|
65
|
+
const conv = createConversation("test");
|
|
66
|
+
const msg1 = await addMessage(conv.id, "user", "first message", undefined, {
|
|
67
|
+
skipIndexing: true,
|
|
68
|
+
});
|
|
69
|
+
const msg2 = await addMessage(
|
|
70
|
+
conv.id,
|
|
71
|
+
"assistant",
|
|
72
|
+
"second message",
|
|
73
|
+
undefined,
|
|
74
|
+
{ skipIndexing: true },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const updated = getConversation(conv.id);
|
|
78
|
+
expect(updated).not.toBeNull();
|
|
79
|
+
// The dirty tail should still point to msg1, not msg2.
|
|
80
|
+
expect(updated!.memoryDirtyTailSinceMessageId).toBe(msg1.id);
|
|
81
|
+
// msg2 should still be persisted normally.
|
|
82
|
+
expect(msg2.id).not.toBe(msg1.id);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("markConversationMemoryDirty is a no-op when already dirty", () => {
|
|
86
|
+
const conv = createConversation("test");
|
|
87
|
+
const firstMessageId = "first-msg-id";
|
|
88
|
+
const secondMessageId = "second-msg-id";
|
|
89
|
+
|
|
90
|
+
markConversationMemoryDirty(conv.id, firstMessageId);
|
|
91
|
+
const after1 = getConversation(conv.id);
|
|
92
|
+
expect(after1!.memoryDirtyTailSinceMessageId).toBe(firstMessageId);
|
|
93
|
+
|
|
94
|
+
markConversationMemoryDirty(conv.id, secondMessageId);
|
|
95
|
+
const after2 = getConversation(conv.id);
|
|
96
|
+
// Still points to the first message — boundary preserved.
|
|
97
|
+
expect(after2!.memoryDirtyTailSinceMessageId).toBe(firstMessageId);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("message ordering and persistence semantics are unchanged", async () => {
|
|
101
|
+
const conv = createConversation("test");
|
|
102
|
+
const msg1 = await addMessage(conv.id, "user", "question", undefined, {
|
|
103
|
+
skipIndexing: true,
|
|
104
|
+
});
|
|
105
|
+
const msg2 = await addMessage(conv.id, "assistant", "answer", undefined, {
|
|
106
|
+
skipIndexing: true,
|
|
107
|
+
});
|
|
108
|
+
const msg3 = await addMessage(conv.id, "user", "follow-up", undefined, {
|
|
109
|
+
skipIndexing: true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const allMessages = getMessages(conv.id);
|
|
113
|
+
expect(allMessages).toHaveLength(3);
|
|
114
|
+
// Messages are ordered by createdAt ascending.
|
|
115
|
+
expect(allMessages[0].id).toBe(msg1.id);
|
|
116
|
+
expect(allMessages[1].id).toBe(msg2.id);
|
|
117
|
+
expect(allMessages[2].id).toBe(msg3.id);
|
|
118
|
+
expect(allMessages[0].content).toBe("question");
|
|
119
|
+
expect(allMessages[1].content).toBe("answer");
|
|
120
|
+
expect(allMessages[2].content).toBe("follow-up");
|
|
121
|
+
// createdAt is monotonically increasing.
|
|
122
|
+
expect(allMessages[1].createdAt).toBeGreaterThan(allMessages[0].createdAt);
|
|
123
|
+
expect(allMessages[2].createdAt).toBeGreaterThan(allMessages[1].createdAt);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("every persisted message marks the conversation dirty", async () => {
|
|
127
|
+
const conv = createConversation("test");
|
|
128
|
+
|
|
129
|
+
// Before any messages, the conversation is not dirty.
|
|
130
|
+
const before = getConversation(conv.id);
|
|
131
|
+
expect(before!.memoryDirtyTailSinceMessageId).toBeNull();
|
|
132
|
+
|
|
133
|
+
// After the first message, it becomes dirty.
|
|
134
|
+
const msg1 = await addMessage(conv.id, "user", "msg1", undefined, {
|
|
135
|
+
skipIndexing: true,
|
|
136
|
+
});
|
|
137
|
+
const after1 = getConversation(conv.id);
|
|
138
|
+
expect(after1!.memoryDirtyTailSinceMessageId).toBe(msg1.id);
|
|
139
|
+
|
|
140
|
+
// After subsequent messages, the dirty boundary stays on msg1.
|
|
141
|
+
await addMessage(conv.id, "assistant", "msg2", undefined, {
|
|
142
|
+
skipIndexing: true,
|
|
143
|
+
});
|
|
144
|
+
await addMessage(conv.id, "user", "msg3", undefined, {
|
|
145
|
+
skipIndexing: true,
|
|
146
|
+
});
|
|
147
|
+
const afterAll = getConversation(conv.id);
|
|
148
|
+
expect(afterAll!.memoryDirtyTailSinceMessageId).toBe(msg1.id);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -27,6 +27,9 @@ mock.module("../providers/registry.js", () => ({
|
|
|
27
27
|
mock.module("../config/loader.js", () => ({
|
|
28
28
|
getConfig: () => ({
|
|
29
29
|
ui: {},
|
|
30
|
+
daemon: {
|
|
31
|
+
titleGenerationMaxTokens: 30,
|
|
32
|
+
},
|
|
30
33
|
|
|
31
34
|
provider: "mock-provider",
|
|
32
35
|
maxTokens: 4096,
|
|
@@ -174,6 +177,10 @@ mock.module("../memory/conversation-queries.js", () => ({
|
|
|
174
177
|
listConversations: () => [],
|
|
175
178
|
}));
|
|
176
179
|
|
|
180
|
+
mock.module("../memory/archive-store.js", () => ({
|
|
181
|
+
insertCompactionEpisode: () => {},
|
|
182
|
+
}));
|
|
183
|
+
|
|
177
184
|
mock.module("../memory/retriever.js", () => ({
|
|
178
185
|
buildMemoryRecall: async () => ({
|
|
179
186
|
enabled: false,
|