@vellumai/assistant 0.5.2 → 0.5.3
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/skills.md +100 -0
- package/package.json +1 -1
- 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-memory-dirty-tail.test.ts +150 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -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__/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-store.test.ts +728 -0
- package/src/__tests__/memory-reducer-types.test.ts +699 -0
- package/src/__tests__/memory-reducer.test.ts +698 -0
- package/src/__tests__/memory-regressions.test.ts +6 -4
- package/src/__tests__/memory-simplified-config.test.ts +281 -0
- package/src/__tests__/parse-identity-fields.test.ts +129 -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/config/bundled-skills/app-builder/SKILL.md +8 -8
- 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/loader.ts +1 -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-runtime-assembly.ts +2 -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/identity.ts +12 -1
- package/src/daemon/lifecycle.ts +9 -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-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 +161 -0
- package/src/memory/brief.ts +75 -0
- package/src/memory/conversation-crud.ts +245 -101
- package/src/memory/db-init.ts +12 -0
- package/src/memory/indexer.ts +106 -15
- package/src/memory/job-handlers/embedding.test.ts +1 -0
- package/src/memory/job-handlers/embedding.ts +83 -0
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +6 -0
- package/src/memory/jobs-worker.ts +12 -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/index.ts +3 -0
- package/src/memory/qdrant-client.ts +23 -4
- package/src/memory/reducer-store.ts +271 -0
- package/src/memory/reducer-types.ts +99 -0
- package/src/memory/reducer.ts +453 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/index.ts +2 -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/routes/conversation-management-routes.ts +6 -0
- package/src/runtime/routes/conversation-query-routes.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +52 -5
- 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 +2 -0
- package/src/runtime/routes/surface-action-routes.ts +68 -1
- package/src/schedule/schedule-store.ts +21 -0
- 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/tools/permission-checker.ts +8 -1
- 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,728 @@
|
|
|
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(), "reducer-store-test-"));
|
|
7
|
+
|
|
8
|
+
mock.module("../util/platform.js", () => ({
|
|
9
|
+
getDataDir: () => testDir,
|
|
10
|
+
getRootDir: () => testDir,
|
|
11
|
+
isMacOS: () => process.platform === "darwin",
|
|
12
|
+
isLinux: () => process.platform === "linux",
|
|
13
|
+
isWindows: () => process.platform === "win32",
|
|
14
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
15
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
16
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
17
|
+
ensureDataDir: () => {},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
mock.module("../util/logger.js", () => ({
|
|
21
|
+
getLogger: () =>
|
|
22
|
+
new Proxy({} as Record<string, unknown>, {
|
|
23
|
+
get: () => () => {},
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { initializeDb, resetDb } from "../memory/db.js";
|
|
28
|
+
import { getSqlite } from "../memory/db-connection.js";
|
|
29
|
+
import { resetTestTables } from "../memory/raw-query.js";
|
|
30
|
+
import {
|
|
31
|
+
applyReducerResult,
|
|
32
|
+
getActiveOpenLoops,
|
|
33
|
+
getActiveTimeContexts,
|
|
34
|
+
} from "../memory/reducer-store.js";
|
|
35
|
+
import type { ReducerResult } from "../memory/reducer-types.js";
|
|
36
|
+
|
|
37
|
+
initializeDb();
|
|
38
|
+
|
|
39
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const SCOPE = "test-scope";
|
|
42
|
+
const NOW = 1_700_000_000_000;
|
|
43
|
+
const HOUR = 60 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
function insertConversation(id: string): void {
|
|
46
|
+
const raw = getSqlite();
|
|
47
|
+
raw.run(
|
|
48
|
+
`INSERT INTO conversations (id, title, created_at, updated_at, conversation_type, source, memory_scope_id, is_auto_title)
|
|
49
|
+
VALUES (?, 'Test', ?, ?, 'standard', 'user', ?, 1)`,
|
|
50
|
+
[id, NOW, NOW, SCOPE],
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function insertMessage(opts: {
|
|
55
|
+
id: string;
|
|
56
|
+
conversationId: string;
|
|
57
|
+
role?: string;
|
|
58
|
+
content?: string;
|
|
59
|
+
createdAt?: number;
|
|
60
|
+
}): void {
|
|
61
|
+
const raw = getSqlite();
|
|
62
|
+
raw.run(
|
|
63
|
+
`INSERT INTO messages (id, conversation_id, role, content, created_at)
|
|
64
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
65
|
+
[
|
|
66
|
+
opts.id,
|
|
67
|
+
opts.conversationId,
|
|
68
|
+
opts.role ?? "user",
|
|
69
|
+
opts.content ?? "test message",
|
|
70
|
+
opts.createdAt ?? NOW,
|
|
71
|
+
],
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getRawConversation(conversationId: string): Record<string, unknown> {
|
|
76
|
+
const raw = getSqlite();
|
|
77
|
+
return raw
|
|
78
|
+
.query(`SELECT * FROM conversations WHERE id = ?`)
|
|
79
|
+
.get(conversationId) as Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getAllTimeContexts(): Array<Record<string, unknown>> {
|
|
83
|
+
const raw = getSqlite();
|
|
84
|
+
return raw.query(`SELECT * FROM time_contexts`).all() as Array<
|
|
85
|
+
Record<string, unknown>
|
|
86
|
+
>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getAllOpenLoops(): Array<Record<string, unknown>> {
|
|
90
|
+
const raw = getSqlite();
|
|
91
|
+
return raw.query(`SELECT * FROM open_loops`).all() as Array<
|
|
92
|
+
Record<string, unknown>
|
|
93
|
+
>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function setDirtyTail(conversationId: string, messageId: string): void {
|
|
97
|
+
const raw = getSqlite();
|
|
98
|
+
raw.run(
|
|
99
|
+
`UPDATE conversations SET memory_dirty_tail_since_message_id = ? WHERE id = ?`,
|
|
100
|
+
[messageId, conversationId],
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function makeEmptyResult(): ReducerResult {
|
|
105
|
+
return {
|
|
106
|
+
timeContexts: [],
|
|
107
|
+
openLoops: [],
|
|
108
|
+
archiveObservations: [],
|
|
109
|
+
archiveEpisodes: [],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Teardown ─────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
afterAll(() => {
|
|
116
|
+
resetDb();
|
|
117
|
+
try {
|
|
118
|
+
rmSync(testDir, { recursive: true });
|
|
119
|
+
} catch {
|
|
120
|
+
/* best effort */
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
resetTestTables("messages", "conversations", "time_contexts", "open_loops");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── Tests ────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
describe("applyReducerResult", () => {
|
|
131
|
+
describe("time context operations", () => {
|
|
132
|
+
test("creates a new time context", () => {
|
|
133
|
+
insertConversation("conv-1");
|
|
134
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
135
|
+
|
|
136
|
+
const result: ReducerResult = {
|
|
137
|
+
...makeEmptyResult(),
|
|
138
|
+
timeContexts: [
|
|
139
|
+
{
|
|
140
|
+
action: "create",
|
|
141
|
+
summary: "User traveling next week",
|
|
142
|
+
source: "conversation",
|
|
143
|
+
activeFrom: NOW,
|
|
144
|
+
activeUntil: NOW + 7 * 24 * HOUR,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
applyReducerResult({
|
|
150
|
+
result,
|
|
151
|
+
conversationId: "conv-1",
|
|
152
|
+
scopeId: SCOPE,
|
|
153
|
+
reducedThroughMessageId: "msg-1",
|
|
154
|
+
now: NOW,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const contexts = getAllTimeContexts();
|
|
158
|
+
expect(contexts).toHaveLength(1);
|
|
159
|
+
expect(contexts[0].summary).toBe("User traveling next week");
|
|
160
|
+
expect(contexts[0].source).toBe("conversation");
|
|
161
|
+
expect(contexts[0].scope_id).toBe(SCOPE);
|
|
162
|
+
expect(contexts[0].active_from).toBe(NOW);
|
|
163
|
+
expect(contexts[0].active_until).toBe(NOW + 7 * 24 * HOUR);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("updates an existing time context", () => {
|
|
167
|
+
insertConversation("conv-1");
|
|
168
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
169
|
+
|
|
170
|
+
// First, create a time context directly
|
|
171
|
+
const raw = getSqlite();
|
|
172
|
+
raw.run(
|
|
173
|
+
`INSERT INTO time_contexts (id, scope_id, summary, source, active_from, active_until, created_at, updated_at)
|
|
174
|
+
VALUES ('tc-1', ?, 'Original summary', 'conversation', ?, ?, ?, ?)`,
|
|
175
|
+
[SCOPE, NOW, NOW + HOUR, NOW, NOW],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const result: ReducerResult = {
|
|
179
|
+
...makeEmptyResult(),
|
|
180
|
+
timeContexts: [
|
|
181
|
+
{
|
|
182
|
+
action: "update",
|
|
183
|
+
id: "tc-1",
|
|
184
|
+
summary: "Updated summary",
|
|
185
|
+
activeUntil: NOW + 2 * HOUR,
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
applyReducerResult({
|
|
191
|
+
result,
|
|
192
|
+
conversationId: "conv-1",
|
|
193
|
+
scopeId: SCOPE,
|
|
194
|
+
reducedThroughMessageId: "msg-1",
|
|
195
|
+
now: NOW + 100,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const contexts = getAllTimeContexts();
|
|
199
|
+
expect(contexts).toHaveLength(1);
|
|
200
|
+
expect(contexts[0].summary).toBe("Updated summary");
|
|
201
|
+
expect(contexts[0].active_until).toBe(NOW + 2 * HOUR);
|
|
202
|
+
expect(contexts[0].updated_at).toBe(NOW + 100);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("resolves (deletes) a time context", () => {
|
|
206
|
+
insertConversation("conv-1");
|
|
207
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
208
|
+
|
|
209
|
+
const raw = getSqlite();
|
|
210
|
+
raw.run(
|
|
211
|
+
`INSERT INTO time_contexts (id, scope_id, summary, source, active_from, active_until, created_at, updated_at)
|
|
212
|
+
VALUES ('tc-1', ?, 'Some context', 'conversation', ?, ?, ?, ?)`,
|
|
213
|
+
[SCOPE, NOW, NOW + HOUR, NOW, NOW],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const result: ReducerResult = {
|
|
217
|
+
...makeEmptyResult(),
|
|
218
|
+
timeContexts: [{ action: "resolve", id: "tc-1" }],
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
applyReducerResult({
|
|
222
|
+
result,
|
|
223
|
+
conversationId: "conv-1",
|
|
224
|
+
scopeId: SCOPE,
|
|
225
|
+
reducedThroughMessageId: "msg-1",
|
|
226
|
+
now: NOW,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(getAllTimeContexts()).toHaveLength(0);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("open loop operations", () => {
|
|
234
|
+
test("creates a new open loop", () => {
|
|
235
|
+
insertConversation("conv-1");
|
|
236
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
237
|
+
|
|
238
|
+
const result: ReducerResult = {
|
|
239
|
+
...makeEmptyResult(),
|
|
240
|
+
openLoops: [
|
|
241
|
+
{
|
|
242
|
+
action: "create",
|
|
243
|
+
summary: "Waiting for Bob's reply",
|
|
244
|
+
source: "conversation",
|
|
245
|
+
dueAt: NOW + 24 * HOUR,
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
applyReducerResult({
|
|
251
|
+
result,
|
|
252
|
+
conversationId: "conv-1",
|
|
253
|
+
scopeId: SCOPE,
|
|
254
|
+
reducedThroughMessageId: "msg-1",
|
|
255
|
+
now: NOW,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const loops = getAllOpenLoops();
|
|
259
|
+
expect(loops).toHaveLength(1);
|
|
260
|
+
expect(loops[0].summary).toBe("Waiting for Bob's reply");
|
|
261
|
+
expect(loops[0].status).toBe("open");
|
|
262
|
+
expect(loops[0].source).toBe("conversation");
|
|
263
|
+
expect(loops[0].due_at).toBe(NOW + 24 * HOUR);
|
|
264
|
+
expect(loops[0].scope_id).toBe(SCOPE);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("creates an open loop without dueAt", () => {
|
|
268
|
+
insertConversation("conv-1");
|
|
269
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
270
|
+
|
|
271
|
+
const result: ReducerResult = {
|
|
272
|
+
...makeEmptyResult(),
|
|
273
|
+
openLoops: [
|
|
274
|
+
{
|
|
275
|
+
action: "create",
|
|
276
|
+
summary: "Need to follow up on project",
|
|
277
|
+
source: "conversation",
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
applyReducerResult({
|
|
283
|
+
result,
|
|
284
|
+
conversationId: "conv-1",
|
|
285
|
+
scopeId: SCOPE,
|
|
286
|
+
reducedThroughMessageId: "msg-1",
|
|
287
|
+
now: NOW,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const loops = getAllOpenLoops();
|
|
291
|
+
expect(loops).toHaveLength(1);
|
|
292
|
+
expect(loops[0].due_at).toBeNull();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("updates an existing open loop", () => {
|
|
296
|
+
insertConversation("conv-1");
|
|
297
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
298
|
+
|
|
299
|
+
const raw = getSqlite();
|
|
300
|
+
raw.run(
|
|
301
|
+
`INSERT INTO open_loops (id, scope_id, summary, status, source, due_at, created_at, updated_at)
|
|
302
|
+
VALUES ('ol-1', ?, 'Original loop', 'open', 'conversation', NULL, ?, ?)`,
|
|
303
|
+
[SCOPE, NOW, NOW],
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const result: ReducerResult = {
|
|
307
|
+
...makeEmptyResult(),
|
|
308
|
+
openLoops: [
|
|
309
|
+
{
|
|
310
|
+
action: "update",
|
|
311
|
+
id: "ol-1",
|
|
312
|
+
summary: "Updated loop summary",
|
|
313
|
+
dueAt: NOW + 48 * HOUR,
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
applyReducerResult({
|
|
319
|
+
result,
|
|
320
|
+
conversationId: "conv-1",
|
|
321
|
+
scopeId: SCOPE,
|
|
322
|
+
reducedThroughMessageId: "msg-1",
|
|
323
|
+
now: NOW + 100,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const loops = getAllOpenLoops();
|
|
327
|
+
expect(loops).toHaveLength(1);
|
|
328
|
+
expect(loops[0].summary).toBe("Updated loop summary");
|
|
329
|
+
expect(loops[0].due_at).toBe(NOW + 48 * HOUR);
|
|
330
|
+
expect(loops[0].updated_at).toBe(NOW + 100);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("resolves an open loop with resolved status", () => {
|
|
334
|
+
insertConversation("conv-1");
|
|
335
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
336
|
+
|
|
337
|
+
const raw = getSqlite();
|
|
338
|
+
raw.run(
|
|
339
|
+
`INSERT INTO open_loops (id, scope_id, summary, status, source, created_at, updated_at)
|
|
340
|
+
VALUES ('ol-1', ?, 'Pending loop', 'open', 'conversation', ?, ?)`,
|
|
341
|
+
[SCOPE, NOW, NOW],
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const result: ReducerResult = {
|
|
345
|
+
...makeEmptyResult(),
|
|
346
|
+
openLoops: [{ action: "resolve", id: "ol-1", status: "resolved" }],
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
applyReducerResult({
|
|
350
|
+
result,
|
|
351
|
+
conversationId: "conv-1",
|
|
352
|
+
scopeId: SCOPE,
|
|
353
|
+
reducedThroughMessageId: "msg-1",
|
|
354
|
+
now: NOW,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const loops = getAllOpenLoops();
|
|
358
|
+
expect(loops).toHaveLength(1);
|
|
359
|
+
expect(loops[0].status).toBe("resolved");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("resolves an open loop with expired status", () => {
|
|
363
|
+
insertConversation("conv-1");
|
|
364
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
365
|
+
|
|
366
|
+
const raw = getSqlite();
|
|
367
|
+
raw.run(
|
|
368
|
+
`INSERT INTO open_loops (id, scope_id, summary, status, source, created_at, updated_at)
|
|
369
|
+
VALUES ('ol-1', ?, 'Expired loop', 'open', 'conversation', ?, ?)`,
|
|
370
|
+
[SCOPE, NOW, NOW],
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const result: ReducerResult = {
|
|
374
|
+
...makeEmptyResult(),
|
|
375
|
+
openLoops: [{ action: "resolve", id: "ol-1", status: "expired" }],
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
applyReducerResult({
|
|
379
|
+
result,
|
|
380
|
+
conversationId: "conv-1",
|
|
381
|
+
scopeId: SCOPE,
|
|
382
|
+
reducedThroughMessageId: "msg-1",
|
|
383
|
+
now: NOW,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const loops = getAllOpenLoops();
|
|
387
|
+
expect(loops).toHaveLength(1);
|
|
388
|
+
expect(loops[0].status).toBe("expired");
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe("checkpoint advancement", () => {
|
|
393
|
+
test("advances memoryReducedThroughMessageId and memoryLastReducedAt", () => {
|
|
394
|
+
insertConversation("conv-1");
|
|
395
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
396
|
+
|
|
397
|
+
applyReducerResult({
|
|
398
|
+
result: makeEmptyResult(),
|
|
399
|
+
conversationId: "conv-1",
|
|
400
|
+
scopeId: SCOPE,
|
|
401
|
+
reducedThroughMessageId: "msg-1",
|
|
402
|
+
now: NOW + 500,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const conv = getRawConversation("conv-1");
|
|
406
|
+
expect(conv.memory_reduced_through_message_id).toBe("msg-1");
|
|
407
|
+
expect(conv.memory_last_reduced_at).toBe(NOW + 500);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("clears dirty tail when fully caught up (no later messages)", () => {
|
|
411
|
+
insertConversation("conv-1");
|
|
412
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
413
|
+
setDirtyTail("conv-1", "msg-1");
|
|
414
|
+
|
|
415
|
+
applyReducerResult({
|
|
416
|
+
result: makeEmptyResult(),
|
|
417
|
+
conversationId: "conv-1",
|
|
418
|
+
scopeId: SCOPE,
|
|
419
|
+
reducedThroughMessageId: "msg-1",
|
|
420
|
+
now: NOW,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const conv = getRawConversation("conv-1");
|
|
424
|
+
expect(conv.memory_dirty_tail_since_message_id).toBeNull();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("preserves dirty tail when messages exist after the reduced-through message", () => {
|
|
428
|
+
insertConversation("conv-1");
|
|
429
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
430
|
+
insertMessage({
|
|
431
|
+
id: "msg-2",
|
|
432
|
+
conversationId: "conv-1",
|
|
433
|
+
createdAt: NOW + 1000,
|
|
434
|
+
});
|
|
435
|
+
setDirtyTail("conv-1", "msg-1");
|
|
436
|
+
|
|
437
|
+
applyReducerResult({
|
|
438
|
+
result: makeEmptyResult(),
|
|
439
|
+
conversationId: "conv-1",
|
|
440
|
+
scopeId: SCOPE,
|
|
441
|
+
reducedThroughMessageId: "msg-1",
|
|
442
|
+
now: NOW,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const conv = getRawConversation("conv-1");
|
|
446
|
+
// Dirty tail should remain since msg-2 still needs reducing
|
|
447
|
+
expect(conv.memory_dirty_tail_since_message_id).toBe("msg-1");
|
|
448
|
+
// But checkpoint should still advance
|
|
449
|
+
expect(conv.memory_reduced_through_message_id).toBe("msg-1");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("advances checkpoint when reducing through the middle of a conversation", () => {
|
|
453
|
+
insertConversation("conv-1");
|
|
454
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
455
|
+
insertMessage({
|
|
456
|
+
id: "msg-2",
|
|
457
|
+
conversationId: "conv-1",
|
|
458
|
+
createdAt: NOW + 1000,
|
|
459
|
+
});
|
|
460
|
+
insertMessage({
|
|
461
|
+
id: "msg-3",
|
|
462
|
+
conversationId: "conv-1",
|
|
463
|
+
createdAt: NOW + 2000,
|
|
464
|
+
});
|
|
465
|
+
setDirtyTail("conv-1", "msg-1");
|
|
466
|
+
|
|
467
|
+
// Reduce through msg-2 (msg-3 still pending)
|
|
468
|
+
applyReducerResult({
|
|
469
|
+
result: makeEmptyResult(),
|
|
470
|
+
conversationId: "conv-1",
|
|
471
|
+
scopeId: SCOPE,
|
|
472
|
+
reducedThroughMessageId: "msg-2",
|
|
473
|
+
now: NOW + 3000,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const conv = getRawConversation("conv-1");
|
|
477
|
+
expect(conv.memory_reduced_through_message_id).toBe("msg-2");
|
|
478
|
+
expect(conv.memory_dirty_tail_since_message_id).toBe("msg-1");
|
|
479
|
+
expect(conv.memory_last_reduced_at).toBe(NOW + 3000);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe("idempotent application", () => {
|
|
484
|
+
test("applying the same result twice leaves state stable", () => {
|
|
485
|
+
insertConversation("conv-1");
|
|
486
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
487
|
+
setDirtyTail("conv-1", "msg-1");
|
|
488
|
+
|
|
489
|
+
const result: ReducerResult = {
|
|
490
|
+
...makeEmptyResult(),
|
|
491
|
+
openLoops: [
|
|
492
|
+
{
|
|
493
|
+
action: "create",
|
|
494
|
+
summary: "Loop from first apply",
|
|
495
|
+
source: "conversation",
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
timeContexts: [
|
|
499
|
+
{
|
|
500
|
+
action: "create",
|
|
501
|
+
summary: "Context from first apply",
|
|
502
|
+
source: "conversation",
|
|
503
|
+
activeFrom: NOW,
|
|
504
|
+
activeUntil: NOW + HOUR,
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const params = {
|
|
510
|
+
result,
|
|
511
|
+
conversationId: "conv-1",
|
|
512
|
+
scopeId: SCOPE,
|
|
513
|
+
reducedThroughMessageId: "msg-1",
|
|
514
|
+
now: NOW,
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// First application
|
|
518
|
+
applyReducerResult(params);
|
|
519
|
+
|
|
520
|
+
const convAfterFirst = getRawConversation("conv-1");
|
|
521
|
+
|
|
522
|
+
// Second application — create ops will insert new rows since UUIDs differ,
|
|
523
|
+
// but checkpoint state should remain consistent
|
|
524
|
+
applyReducerResult(params);
|
|
525
|
+
|
|
526
|
+
const convAfterSecond = getRawConversation("conv-1");
|
|
527
|
+
|
|
528
|
+
// Checkpoint columns should be stable
|
|
529
|
+
expect(convAfterSecond.memory_reduced_through_message_id).toBe(
|
|
530
|
+
convAfterFirst.memory_reduced_through_message_id,
|
|
531
|
+
);
|
|
532
|
+
expect(convAfterSecond.memory_last_reduced_at).toBe(
|
|
533
|
+
convAfterFirst.memory_last_reduced_at,
|
|
534
|
+
);
|
|
535
|
+
expect(convAfterSecond.memory_dirty_tail_since_message_id).toBe(
|
|
536
|
+
convAfterFirst.memory_dirty_tail_since_message_id,
|
|
537
|
+
);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("applying an empty result still advances the checkpoint", () => {
|
|
541
|
+
insertConversation("conv-1");
|
|
542
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
543
|
+
setDirtyTail("conv-1", "msg-1");
|
|
544
|
+
|
|
545
|
+
applyReducerResult({
|
|
546
|
+
result: makeEmptyResult(),
|
|
547
|
+
conversationId: "conv-1",
|
|
548
|
+
scopeId: SCOPE,
|
|
549
|
+
reducedThroughMessageId: "msg-1",
|
|
550
|
+
now: NOW + 100,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const conv = getRawConversation("conv-1");
|
|
554
|
+
expect(conv.memory_reduced_through_message_id).toBe("msg-1");
|
|
555
|
+
expect(conv.memory_last_reduced_at).toBe(NOW + 100);
|
|
556
|
+
expect(conv.memory_dirty_tail_since_message_id).toBeNull();
|
|
557
|
+
|
|
558
|
+
// No side-effects on brief-state tables
|
|
559
|
+
expect(getAllTimeContexts()).toHaveLength(0);
|
|
560
|
+
expect(getAllOpenLoops()).toHaveLength(0);
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
describe("mixed operations in a single result", () => {
|
|
565
|
+
test("creates, updates, and resolves across both tables atomically", () => {
|
|
566
|
+
insertConversation("conv-1");
|
|
567
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
568
|
+
|
|
569
|
+
// Pre-existing rows to update/resolve
|
|
570
|
+
const raw = getSqlite();
|
|
571
|
+
raw.run(
|
|
572
|
+
`INSERT INTO time_contexts (id, scope_id, summary, source, active_from, active_until, created_at, updated_at)
|
|
573
|
+
VALUES ('tc-existing', ?, 'Will be updated', 'conversation', ?, ?, ?, ?)`,
|
|
574
|
+
[SCOPE, NOW, NOW + HOUR, NOW, NOW],
|
|
575
|
+
);
|
|
576
|
+
raw.run(
|
|
577
|
+
`INSERT INTO time_contexts (id, scope_id, summary, source, active_from, active_until, created_at, updated_at)
|
|
578
|
+
VALUES ('tc-stale', ?, 'Will be resolved', 'conversation', ?, ?, ?, ?)`,
|
|
579
|
+
[SCOPE, NOW - HOUR, NOW, NOW - HOUR, NOW - HOUR],
|
|
580
|
+
);
|
|
581
|
+
raw.run(
|
|
582
|
+
`INSERT INTO open_loops (id, scope_id, summary, status, source, created_at, updated_at)
|
|
583
|
+
VALUES ('ol-existing', ?, 'Will be resolved', 'open', 'conversation', ?, ?)`,
|
|
584
|
+
[SCOPE, NOW, NOW],
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
const result: ReducerResult = {
|
|
588
|
+
...makeEmptyResult(),
|
|
589
|
+
timeContexts: [
|
|
590
|
+
{
|
|
591
|
+
action: "create",
|
|
592
|
+
summary: "New context",
|
|
593
|
+
source: "conversation",
|
|
594
|
+
activeFrom: NOW,
|
|
595
|
+
activeUntil: NOW + 2 * HOUR,
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
action: "update",
|
|
599
|
+
id: "tc-existing",
|
|
600
|
+
summary: "Updated context",
|
|
601
|
+
},
|
|
602
|
+
{ action: "resolve", id: "tc-stale" },
|
|
603
|
+
],
|
|
604
|
+
openLoops: [
|
|
605
|
+
{
|
|
606
|
+
action: "create",
|
|
607
|
+
summary: "New loop",
|
|
608
|
+
source: "conversation",
|
|
609
|
+
},
|
|
610
|
+
{ action: "resolve", id: "ol-existing", status: "resolved" },
|
|
611
|
+
],
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
applyReducerResult({
|
|
615
|
+
result,
|
|
616
|
+
conversationId: "conv-1",
|
|
617
|
+
scopeId: SCOPE,
|
|
618
|
+
reducedThroughMessageId: "msg-1",
|
|
619
|
+
now: NOW + 200,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// Time contexts: 1 created + 1 updated (tc-stale deleted by resolve)
|
|
623
|
+
const contexts = getAllTimeContexts();
|
|
624
|
+
expect(contexts).toHaveLength(2);
|
|
625
|
+
const updated = contexts.find((c) => c.id === "tc-existing");
|
|
626
|
+
expect(updated?.summary).toBe("Updated context");
|
|
627
|
+
const created = contexts.find((c) => c.id !== "tc-existing");
|
|
628
|
+
expect(created?.summary).toBe("New context");
|
|
629
|
+
|
|
630
|
+
// Open loops: 1 created + 1 resolved
|
|
631
|
+
const loops = getAllOpenLoops();
|
|
632
|
+
expect(loops).toHaveLength(2);
|
|
633
|
+
const resolvedLoop = loops.find((l) => l.id === "ol-existing");
|
|
634
|
+
expect(resolvedLoop?.status).toBe("resolved");
|
|
635
|
+
const newLoop = loops.find((l) => l.id !== "ol-existing");
|
|
636
|
+
expect(newLoop?.status).toBe("open");
|
|
637
|
+
expect(newLoop?.summary).toBe("New loop");
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
describe("archive candidates are ignored", () => {
|
|
642
|
+
test("archive observations and episodes in result are not persisted", () => {
|
|
643
|
+
insertConversation("conv-1");
|
|
644
|
+
insertMessage({ id: "msg-1", conversationId: "conv-1", createdAt: NOW });
|
|
645
|
+
|
|
646
|
+
const result: ReducerResult = {
|
|
647
|
+
timeContexts: [],
|
|
648
|
+
openLoops: [],
|
|
649
|
+
archiveObservations: [{ content: "User likes coffee", role: "user" }],
|
|
650
|
+
archiveEpisodes: [
|
|
651
|
+
{ title: "Coffee discussion", summary: "Talked about coffee" },
|
|
652
|
+
],
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// Should not throw and should not attempt to write archive data
|
|
656
|
+
applyReducerResult({
|
|
657
|
+
result,
|
|
658
|
+
conversationId: "conv-1",
|
|
659
|
+
scopeId: SCOPE,
|
|
660
|
+
reducedThroughMessageId: "msg-1",
|
|
661
|
+
now: NOW,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// Checkpoint should still advance
|
|
665
|
+
const conv = getRawConversation("conv-1");
|
|
666
|
+
expect(conv.memory_reduced_through_message_id).toBe("msg-1");
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
describe("getActiveTimeContexts", () => {
|
|
672
|
+
test("returns only non-expired time contexts for the scope", () => {
|
|
673
|
+
const raw = getSqlite();
|
|
674
|
+
// Active context (expires in the future)
|
|
675
|
+
raw.run(
|
|
676
|
+
`INSERT INTO time_contexts (id, scope_id, summary, source, active_from, active_until, created_at, updated_at)
|
|
677
|
+
VALUES ('tc-active', ?, 'Active context', 'conversation', ?, ?, ?, ?)`,
|
|
678
|
+
[SCOPE, NOW - HOUR, NOW + HOUR, NOW, NOW],
|
|
679
|
+
);
|
|
680
|
+
// Expired context
|
|
681
|
+
raw.run(
|
|
682
|
+
`INSERT INTO time_contexts (id, scope_id, summary, source, active_from, active_until, created_at, updated_at)
|
|
683
|
+
VALUES ('tc-expired', ?, 'Expired context', 'conversation', ?, ?, ?, ?)`,
|
|
684
|
+
[SCOPE, NOW - 2 * HOUR, NOW - HOUR, NOW, NOW],
|
|
685
|
+
);
|
|
686
|
+
// Different scope
|
|
687
|
+
raw.run(
|
|
688
|
+
`INSERT INTO time_contexts (id, scope_id, summary, source, active_from, active_until, created_at, updated_at)
|
|
689
|
+
VALUES ('tc-other', 'other-scope', 'Other scope context', 'conversation', ?, ?, ?, ?)`,
|
|
690
|
+
[NOW, NOW + HOUR, NOW, NOW],
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
const active = getActiveTimeContexts(SCOPE, NOW);
|
|
694
|
+
expect(active).toHaveLength(1);
|
|
695
|
+
expect(active[0].id).toBe("tc-active");
|
|
696
|
+
expect(active[0].summary).toBe("Active context");
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
describe("getActiveOpenLoops", () => {
|
|
701
|
+
test("returns only open loops for the scope", () => {
|
|
702
|
+
const raw = getSqlite();
|
|
703
|
+
// Open loop
|
|
704
|
+
raw.run(
|
|
705
|
+
`INSERT INTO open_loops (id, scope_id, summary, status, source, created_at, updated_at)
|
|
706
|
+
VALUES ('ol-open', ?, 'Open loop', 'open', 'conversation', ?, ?)`,
|
|
707
|
+
[SCOPE, NOW, NOW],
|
|
708
|
+
);
|
|
709
|
+
// Resolved loop
|
|
710
|
+
raw.run(
|
|
711
|
+
`INSERT INTO open_loops (id, scope_id, summary, status, source, created_at, updated_at)
|
|
712
|
+
VALUES ('ol-resolved', ?, 'Resolved loop', 'resolved', 'conversation', ?, ?)`,
|
|
713
|
+
[SCOPE, NOW, NOW],
|
|
714
|
+
);
|
|
715
|
+
// Different scope
|
|
716
|
+
raw.run(
|
|
717
|
+
`INSERT INTO open_loops (id, scope_id, summary, status, source, created_at, updated_at)
|
|
718
|
+
VALUES ('ol-other', 'other-scope', 'Other scope loop', 'open', 'conversation', ?, ?)`,
|
|
719
|
+
[NOW, NOW],
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
const active = getActiveOpenLoops(SCOPE);
|
|
723
|
+
expect(active).toHaveLength(1);
|
|
724
|
+
expect(active[0].id).toBe("ol-open");
|
|
725
|
+
expect(active[0].summary).toBe("Open loop");
|
|
726
|
+
expect(active[0].status).toBe("open");
|
|
727
|
+
});
|
|
728
|
+
});
|