@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
|
@@ -190,3 +190,99 @@ describe("handleListMessages attachments", () => {
|
|
|
190
190
|
expect(docAtt!.data).toBeUndefined();
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
|
+
|
|
194
|
+
describe("handleListMessages no_response filtering", () => {
|
|
195
|
+
beforeEach(resetTables);
|
|
196
|
+
|
|
197
|
+
test("strips <no_response/> from assistant message content", async () => {
|
|
198
|
+
const conv = createConversation();
|
|
199
|
+
await addMessage(
|
|
200
|
+
conv.id,
|
|
201
|
+
"assistant",
|
|
202
|
+
JSON.stringify([{ type: "text", text: "<no_response/>" }]),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const response = handleListMessages(createTestUrl(conv.id), null);
|
|
206
|
+
const body = (await response.json()) as {
|
|
207
|
+
messages: { content: string; textSegments: string[] }[];
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
expect(body.messages).toHaveLength(1);
|
|
211
|
+
expect(body.messages[0].content).toBe("");
|
|
212
|
+
// textSegments is omitted from payload when empty
|
|
213
|
+
expect(body.messages[0].textSegments).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("strips <no_response/> but keeps other text segments", async () => {
|
|
217
|
+
const conv = createConversation();
|
|
218
|
+
await addMessage(
|
|
219
|
+
conv.id,
|
|
220
|
+
"assistant",
|
|
221
|
+
JSON.stringify([
|
|
222
|
+
{ type: "text", text: "<no_response/>" },
|
|
223
|
+
{ type: "text", text: "Real reply." },
|
|
224
|
+
]),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const response = handleListMessages(createTestUrl(conv.id), null);
|
|
228
|
+
const body = (await response.json()) as {
|
|
229
|
+
messages: { content: string; textSegments: string[] }[];
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
expect(body.messages).toHaveLength(1);
|
|
233
|
+
expect(body.messages[0].content).toBe("Real reply.");
|
|
234
|
+
expect(body.messages[0].textSegments).toEqual(["Real reply."]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("remaps contentOrder when <no_response/> segment is removed", async () => {
|
|
238
|
+
const conv = createConversation();
|
|
239
|
+
// Simulate: text("<no_response/>") -> tool_use -> tool_result -> text("Answer")
|
|
240
|
+
await addMessage(
|
|
241
|
+
conv.id,
|
|
242
|
+
"assistant",
|
|
243
|
+
JSON.stringify([
|
|
244
|
+
{ type: "text", text: "<no_response/>" },
|
|
245
|
+
{
|
|
246
|
+
type: "tool_use",
|
|
247
|
+
id: "tu1",
|
|
248
|
+
name: "search",
|
|
249
|
+
input: { q: "test" },
|
|
250
|
+
},
|
|
251
|
+
{ type: "tool_result", tool_use_id: "tu1", content: "result" },
|
|
252
|
+
{ type: "text", text: "Answer" },
|
|
253
|
+
]),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const response = handleListMessages(createTestUrl(conv.id), null);
|
|
257
|
+
const body = (await response.json()) as {
|
|
258
|
+
messages: {
|
|
259
|
+
content: string;
|
|
260
|
+
textSegments: string[];
|
|
261
|
+
contentOrder: string[];
|
|
262
|
+
}[];
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
expect(body.messages).toHaveLength(1);
|
|
266
|
+
expect(body.messages[0].textSegments).toEqual(["Answer"]);
|
|
267
|
+
// text:0 (no_response) should be removed, text:1 remapped to text:0
|
|
268
|
+
expect(body.messages[0].contentOrder).toContain("text:0");
|
|
269
|
+
expect(body.messages[0].contentOrder).not.toContain("text:1");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("does not strip <no_response/> from user messages", async () => {
|
|
273
|
+
const conv = createConversation();
|
|
274
|
+
await addMessage(
|
|
275
|
+
conv.id,
|
|
276
|
+
"user",
|
|
277
|
+
JSON.stringify([{ type: "text", text: "What does <no_response/> do?" }]),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const response = handleListMessages(createTestUrl(conv.id), null);
|
|
281
|
+
const body = (await response.json()) as {
|
|
282
|
+
messages: { content: string }[];
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
expect(body.messages).toHaveLength(1);
|
|
286
|
+
expect(body.messages[0].content).toBe("What does <no_response/> do?");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,530 @@
|
|
|
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(), "brief-open-loops-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 { createFollowUp } from "../followups/followup-store.js";
|
|
28
|
+
import { compileOpenLoopBrief } from "../memory/brief-open-loops.js";
|
|
29
|
+
import { initializeDb, resetDb } from "../memory/db.js";
|
|
30
|
+
import { getSqlite } from "../memory/db-connection.js";
|
|
31
|
+
import { resetTestTables } from "../memory/raw-query.js";
|
|
32
|
+
import { createTask } from "../tasks/task-store.js";
|
|
33
|
+
|
|
34
|
+
initializeDb();
|
|
35
|
+
|
|
36
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const SCOPE = "test-scope";
|
|
39
|
+
const MS_HOUR = 60 * 60 * 1000;
|
|
40
|
+
const MS_DAY = 24 * MS_HOUR;
|
|
41
|
+
|
|
42
|
+
/** Get the raw bun:sqlite Database for parameterized inserts. */
|
|
43
|
+
function getRawDb(): import("bun:sqlite").Database {
|
|
44
|
+
return getSqlite();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function insertOpenLoop(opts: {
|
|
48
|
+
id: string;
|
|
49
|
+
summary: string;
|
|
50
|
+
dueAt?: number | null;
|
|
51
|
+
surfacedAt?: number | null;
|
|
52
|
+
status?: string;
|
|
53
|
+
updatedAt?: number;
|
|
54
|
+
createdAt?: number;
|
|
55
|
+
}): void {
|
|
56
|
+
const raw = getRawDb();
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
raw.run(
|
|
59
|
+
`INSERT INTO open_loops (id, scope_id, summary, status, source, due_at, surfaced_at, created_at, updated_at)
|
|
60
|
+
VALUES (?, ?, ?, ?, 'conversation', ?, ?, ?, ?)`,
|
|
61
|
+
[
|
|
62
|
+
opts.id,
|
|
63
|
+
SCOPE,
|
|
64
|
+
opts.summary,
|
|
65
|
+
opts.status ?? "open",
|
|
66
|
+
opts.dueAt ?? null,
|
|
67
|
+
opts.surfacedAt ?? null,
|
|
68
|
+
opts.createdAt ?? now,
|
|
69
|
+
opts.updatedAt ?? now,
|
|
70
|
+
],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function insertWorkItem(opts: {
|
|
75
|
+
id: string;
|
|
76
|
+
taskId: string;
|
|
77
|
+
title: string;
|
|
78
|
+
status?: string;
|
|
79
|
+
priorityTier?: number;
|
|
80
|
+
updatedAt?: number;
|
|
81
|
+
}): void {
|
|
82
|
+
const raw = getRawDb();
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
raw.run(
|
|
85
|
+
`INSERT INTO work_items (id, task_id, title, status, priority_tier, sort_index, created_at, updated_at)
|
|
86
|
+
VALUES (?, ?, ?, ?, ?, 0, ?, ?)`,
|
|
87
|
+
[
|
|
88
|
+
opts.id,
|
|
89
|
+
opts.taskId,
|
|
90
|
+
opts.title,
|
|
91
|
+
opts.status ?? "queued",
|
|
92
|
+
opts.priorityTier ?? 1,
|
|
93
|
+
now,
|
|
94
|
+
opts.updatedAt ?? now,
|
|
95
|
+
],
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Teardown ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
afterAll(() => {
|
|
102
|
+
resetDb();
|
|
103
|
+
try {
|
|
104
|
+
rmSync(testDir, { recursive: true });
|
|
105
|
+
} catch {
|
|
106
|
+
/* best effort */
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
resetTestTables(
|
|
112
|
+
"open_loops",
|
|
113
|
+
"work_items",
|
|
114
|
+
"tasks",
|
|
115
|
+
"task_runs",
|
|
116
|
+
"followups",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── Tests ────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
describe("compileOpenLoopBrief", () => {
|
|
123
|
+
test("returns empty when no data exists", () => {
|
|
124
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1");
|
|
125
|
+
expect(result.bullets).toEqual([]);
|
|
126
|
+
expect(result.resurfacedLoopId).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── Tier ranking ──────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
describe("tier ranking", () => {
|
|
132
|
+
test("overdue loops are tier 1", () => {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
insertOpenLoop({
|
|
135
|
+
id: "ol-overdue",
|
|
136
|
+
summary: "Overdue task",
|
|
137
|
+
dueAt: now - MS_HOUR,
|
|
138
|
+
updatedAt: now - MS_DAY * 10,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
142
|
+
expect(result.bullets).toHaveLength(1);
|
|
143
|
+
expect(result.bullets[0].tier).toBe(1);
|
|
144
|
+
expect(result.bullets[0].summary).toBe("Overdue task");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("loops due within 24h are tier 2", () => {
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
insertOpenLoop({
|
|
150
|
+
id: "ol-24h",
|
|
151
|
+
summary: "Due soon",
|
|
152
|
+
dueAt: now + 12 * MS_HOUR,
|
|
153
|
+
updatedAt: now - MS_DAY * 10,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
157
|
+
expect(result.bullets).toHaveLength(1);
|
|
158
|
+
expect(result.bullets[0].tier).toBe(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("loops due within 7d are tier 3", () => {
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
insertOpenLoop({
|
|
164
|
+
id: "ol-7d",
|
|
165
|
+
summary: "Due this week",
|
|
166
|
+
dueAt: now + 3 * MS_DAY,
|
|
167
|
+
updatedAt: now - MS_DAY * 10,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
171
|
+
expect(result.bullets).toHaveLength(1);
|
|
172
|
+
expect(result.bullets[0].tier).toBe(3);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("high-priority work items are tier 4", () => {
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
const task = createTask({ title: "t", template: "t" });
|
|
178
|
+
insertWorkItem({
|
|
179
|
+
id: "wi-high",
|
|
180
|
+
taskId: task.id,
|
|
181
|
+
title: "High priority item",
|
|
182
|
+
priorityTier: 0,
|
|
183
|
+
updatedAt: now - MS_DAY * 10,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
187
|
+
const wiBullet = result.bullets.find((b) => b.source === "work_item");
|
|
188
|
+
expect(wiBullet).toBeDefined();
|
|
189
|
+
expect(wiBullet!.tier).toBe(4);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("recently touched loops are tier 5", () => {
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
insertOpenLoop({
|
|
195
|
+
id: "ol-recent",
|
|
196
|
+
summary: "Just updated",
|
|
197
|
+
updatedAt: now - MS_HOUR,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
201
|
+
expect(result.bullets).toHaveLength(1);
|
|
202
|
+
expect(result.bullets[0].tier).toBe(5);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("bullets are sorted by tier ascending", () => {
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
insertOpenLoop({
|
|
208
|
+
id: "ol-tier3",
|
|
209
|
+
summary: "Due this week",
|
|
210
|
+
dueAt: now + 3 * MS_DAY,
|
|
211
|
+
updatedAt: now - MS_DAY * 10,
|
|
212
|
+
});
|
|
213
|
+
insertOpenLoop({
|
|
214
|
+
id: "ol-tier1",
|
|
215
|
+
summary: "Overdue",
|
|
216
|
+
dueAt: now - MS_HOUR,
|
|
217
|
+
updatedAt: now - MS_DAY * 10,
|
|
218
|
+
});
|
|
219
|
+
insertOpenLoop({
|
|
220
|
+
id: "ol-tier5",
|
|
221
|
+
summary: "Recent",
|
|
222
|
+
updatedAt: now - MS_HOUR,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
226
|
+
const tiers = result.bullets.map((b) => b.tier);
|
|
227
|
+
expect(tiers).toEqual([...tiers].sort((a, b) => a - b));
|
|
228
|
+
expect(tiers[0]).toBe(1);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── Deduplication ─────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
describe("deduplication", () => {
|
|
235
|
+
test("work items with the same summary as an open loop are deduplicated", () => {
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
const task = createTask({ title: "t", template: "t" });
|
|
238
|
+
|
|
239
|
+
insertOpenLoop({
|
|
240
|
+
id: "ol-dup",
|
|
241
|
+
summary: "Review PR",
|
|
242
|
+
dueAt: now + MS_HOUR,
|
|
243
|
+
updatedAt: now,
|
|
244
|
+
});
|
|
245
|
+
insertWorkItem({
|
|
246
|
+
id: "wi-dup",
|
|
247
|
+
taskId: task.id,
|
|
248
|
+
title: "Review PR",
|
|
249
|
+
updatedAt: now,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
253
|
+
const summaries = result.bullets.map((b) => b.summary);
|
|
254
|
+
// Should only appear once (from the loop)
|
|
255
|
+
expect(summaries.filter((s) => s === "Review PR")).toHaveLength(1);
|
|
256
|
+
expect(
|
|
257
|
+
result.bullets.find((b) => b.summary === "Review PR")!.source,
|
|
258
|
+
).toBe("loop");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("case-insensitive deduplication", () => {
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
const task = createTask({ title: "t", template: "t" });
|
|
264
|
+
|
|
265
|
+
insertOpenLoop({
|
|
266
|
+
id: "ol-case",
|
|
267
|
+
summary: "deploy release",
|
|
268
|
+
dueAt: now + MS_HOUR,
|
|
269
|
+
updatedAt: now,
|
|
270
|
+
});
|
|
271
|
+
insertWorkItem({
|
|
272
|
+
id: "wi-case",
|
|
273
|
+
taskId: task.id,
|
|
274
|
+
title: "Deploy Release",
|
|
275
|
+
updatedAt: now,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
279
|
+
const matching = result.bullets.filter(
|
|
280
|
+
(b) => b.summary.toLowerCase() === "deploy release",
|
|
281
|
+
);
|
|
282
|
+
expect(matching).toHaveLength(1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("unique keys are not deduplicated", () => {
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
const task = createTask({ title: "t", template: "t" });
|
|
288
|
+
|
|
289
|
+
insertOpenLoop({
|
|
290
|
+
id: "ol-a",
|
|
291
|
+
summary: "Fix bug",
|
|
292
|
+
dueAt: now + MS_HOUR,
|
|
293
|
+
updatedAt: now,
|
|
294
|
+
});
|
|
295
|
+
insertWorkItem({
|
|
296
|
+
id: "wi-b",
|
|
297
|
+
taskId: task.id,
|
|
298
|
+
title: "Write tests",
|
|
299
|
+
updatedAt: now,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
303
|
+
expect(result.bullets).toHaveLength(2);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ── Follow-up integration ────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
describe("follow-ups", () => {
|
|
310
|
+
test("overdue follow-ups appear as tier 1", () => {
|
|
311
|
+
const now = Date.now();
|
|
312
|
+
createFollowUp({
|
|
313
|
+
channel: "email",
|
|
314
|
+
conversationId: "conv-1",
|
|
315
|
+
expectedResponseBy: now - MS_HOUR,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
319
|
+
const fuBullet = result.bullets.find((b) => b.source === "followup");
|
|
320
|
+
expect(fuBullet).toBeDefined();
|
|
321
|
+
expect(fuBullet!.tier).toBe(1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("pending follow-ups due within 24h are tier 2", () => {
|
|
325
|
+
const now = Date.now();
|
|
326
|
+
createFollowUp({
|
|
327
|
+
channel: "slack",
|
|
328
|
+
conversationId: "conv-2",
|
|
329
|
+
expectedResponseBy: now + 12 * MS_HOUR,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-1", now);
|
|
333
|
+
const fuBullet = result.bullets.find((b) => b.source === "followup");
|
|
334
|
+
expect(fuBullet).toBeDefined();
|
|
335
|
+
expect(fuBullet!.tier).toBe(2);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── Deterministic resurfacing ────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
describe("deterministic resurfacing", () => {
|
|
342
|
+
test("resurfaces one low-salience loop from open_loops", () => {
|
|
343
|
+
const now = Date.now();
|
|
344
|
+
// Create loops that are old and have no due date → tier 6 (low salience)
|
|
345
|
+
insertOpenLoop({
|
|
346
|
+
id: "ol-old-1",
|
|
347
|
+
summary: "Old loop 1",
|
|
348
|
+
updatedAt: now - MS_DAY * 30,
|
|
349
|
+
createdAt: now - MS_DAY * 30,
|
|
350
|
+
});
|
|
351
|
+
insertOpenLoop({
|
|
352
|
+
id: "ol-old-2",
|
|
353
|
+
summary: "Old loop 2",
|
|
354
|
+
updatedAt: now - MS_DAY * 30,
|
|
355
|
+
createdAt: now - MS_DAY * 30,
|
|
356
|
+
});
|
|
357
|
+
insertOpenLoop({
|
|
358
|
+
id: "ol-old-3",
|
|
359
|
+
summary: "Old loop 3",
|
|
360
|
+
updatedAt: now - MS_DAY * 30,
|
|
361
|
+
createdAt: now - MS_DAY * 30,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-resurface", now);
|
|
365
|
+
|
|
366
|
+
// Exactly one should be resurfaced
|
|
367
|
+
expect(result.resurfacedLoopId).not.toBeNull();
|
|
368
|
+
expect(result.bullets).toHaveLength(1);
|
|
369
|
+
expect(result.bullets[0].tier).toBe(5);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("resurfacing is deterministic for the same seed", () => {
|
|
373
|
+
const now = Date.now();
|
|
374
|
+
for (let i = 1; i <= 5; i++) {
|
|
375
|
+
insertOpenLoop({
|
|
376
|
+
id: `ol-det-${i}`,
|
|
377
|
+
summary: `Deterministic loop ${i}`,
|
|
378
|
+
updatedAt: now - MS_DAY * 30,
|
|
379
|
+
createdAt: now - MS_DAY * 30,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const r1 = compileOpenLoopBrief(SCOPE, "msg-det", now);
|
|
384
|
+
|
|
385
|
+
// Reset surfacedAt and updatedAt so the second call has same candidates
|
|
386
|
+
// (updateLastSurfacedAt also writes updatedAt, which would change tier)
|
|
387
|
+
const oldUpdatedAt = now - MS_DAY * 30;
|
|
388
|
+
getRawDb().run(
|
|
389
|
+
`UPDATE open_loops SET surfaced_at = NULL, updated_at = ?`,
|
|
390
|
+
[oldUpdatedAt],
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const r2 = compileOpenLoopBrief(SCOPE, "msg-det", now);
|
|
394
|
+
|
|
395
|
+
expect(r1.resurfacedLoopId).toBe(r2.resurfacedLoopId);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("different userMessageId produces different selection", () => {
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
// Need enough loops that different seeds are likely to pick different ones
|
|
401
|
+
for (let i = 1; i <= 20; i++) {
|
|
402
|
+
insertOpenLoop({
|
|
403
|
+
id: `ol-vary-${i}`,
|
|
404
|
+
summary: `Varying loop ${i}`,
|
|
405
|
+
updatedAt: now - MS_DAY * 30,
|
|
406
|
+
createdAt: now - MS_DAY * 30,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const selections = new Set<string | null>();
|
|
411
|
+
const oldUpdatedAt = now - MS_DAY * 30;
|
|
412
|
+
for (let i = 0; i < 10; i++) {
|
|
413
|
+
// Reset surfacedAt and updatedAt between calls so all loops stay low-salience
|
|
414
|
+
getRawDb().run(
|
|
415
|
+
`UPDATE open_loops SET surfaced_at = NULL, updated_at = ?`,
|
|
416
|
+
[oldUpdatedAt],
|
|
417
|
+
);
|
|
418
|
+
const r = compileOpenLoopBrief(SCOPE, `msg-vary-${i}`, now);
|
|
419
|
+
selections.add(r.resurfacedLoopId);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// With 20 candidates and 10 different seeds, we should see at least 2
|
|
423
|
+
// different selections (overwhelmingly likely)
|
|
424
|
+
expect(selections.size).toBeGreaterThanOrEqual(2);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("updates surfacedAt on the resurfaced loop", () => {
|
|
428
|
+
const now = Date.now();
|
|
429
|
+
insertOpenLoop({
|
|
430
|
+
id: "ol-surf",
|
|
431
|
+
summary: "Will be surfaced",
|
|
432
|
+
updatedAt: now - MS_DAY * 30,
|
|
433
|
+
createdAt: now - MS_DAY * 30,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-surf", now);
|
|
437
|
+
expect(result.resurfacedLoopId).toBe("ol-surf");
|
|
438
|
+
|
|
439
|
+
// Verify surfacedAt was written
|
|
440
|
+
const surfaced = getRawDb()
|
|
441
|
+
.query(`SELECT surfaced_at FROM open_loops WHERE id = 'ol-surf'`)
|
|
442
|
+
.get() as { surfaced_at: number } | null;
|
|
443
|
+
expect(surfaced).not.toBeNull();
|
|
444
|
+
expect(surfaced!.surfaced_at).toBe(now);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("low-salience work items are not resurfaced", () => {
|
|
448
|
+
const now = Date.now();
|
|
449
|
+
const task = createTask({ title: "t", template: "t" });
|
|
450
|
+
|
|
451
|
+
// Only work items, no loops — should not resurface
|
|
452
|
+
insertWorkItem({
|
|
453
|
+
id: "wi-old",
|
|
454
|
+
taskId: task.id,
|
|
455
|
+
title: "Old work item",
|
|
456
|
+
updatedAt: now - MS_DAY * 30,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-no-resurface", now);
|
|
460
|
+
expect(result.resurfacedLoopId).toBeNull();
|
|
461
|
+
// Work item is tier 6, so it should not appear in ranked output
|
|
462
|
+
expect(result.bullets).toHaveLength(0);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ── Scope isolation ──────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
describe("scope isolation", () => {
|
|
469
|
+
test("only includes loops from the specified scope", () => {
|
|
470
|
+
const now = Date.now();
|
|
471
|
+
insertOpenLoop({
|
|
472
|
+
id: "ol-scope-a",
|
|
473
|
+
summary: "In scope",
|
|
474
|
+
dueAt: now + MS_HOUR,
|
|
475
|
+
updatedAt: now,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Insert loop for a different scope directly
|
|
479
|
+
getRawDb().run(
|
|
480
|
+
`INSERT INTO open_loops (id, scope_id, summary, status, source, due_at, created_at, updated_at)
|
|
481
|
+
VALUES ('ol-scope-b', 'other-scope', 'Out of scope', 'open', 'conversation', ?, ?, ?)`,
|
|
482
|
+
[now + MS_HOUR, now, now],
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-scope", now);
|
|
486
|
+
expect(result.bullets).toHaveLength(1);
|
|
487
|
+
expect(result.bullets[0].summary).toBe("In scope");
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// ── Mixed sources ────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
describe("mixed sources", () => {
|
|
494
|
+
test("merges loops, work items, and follow-ups without duplicates", () => {
|
|
495
|
+
const now = Date.now();
|
|
496
|
+
const task = createTask({ title: "t", template: "t" });
|
|
497
|
+
|
|
498
|
+
insertOpenLoop({
|
|
499
|
+
id: "ol-mix",
|
|
500
|
+
summary: "Loop item",
|
|
501
|
+
dueAt: now - MS_HOUR,
|
|
502
|
+
updatedAt: now,
|
|
503
|
+
});
|
|
504
|
+
insertWorkItem({
|
|
505
|
+
id: "wi-mix",
|
|
506
|
+
taskId: task.id,
|
|
507
|
+
title: "Work item",
|
|
508
|
+
priorityTier: 0,
|
|
509
|
+
updatedAt: now,
|
|
510
|
+
});
|
|
511
|
+
createFollowUp({
|
|
512
|
+
channel: "email",
|
|
513
|
+
conversationId: "conv-mix",
|
|
514
|
+
expectedResponseBy: now + 6 * MS_HOUR,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const result = compileOpenLoopBrief(SCOPE, "msg-mix", now);
|
|
518
|
+
expect(result.bullets).toHaveLength(3);
|
|
519
|
+
|
|
520
|
+
const sources = result.bullets.map((b) => b.source);
|
|
521
|
+
expect(sources).toContain("loop");
|
|
522
|
+
expect(sources).toContain("work_item");
|
|
523
|
+
expect(sources).toContain("followup");
|
|
524
|
+
|
|
525
|
+
// All keys are unique
|
|
526
|
+
const keys = result.bullets.map((b) => b.key);
|
|
527
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
});
|