zubo 0.1.21 → 0.1.23
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/.playwright-mcp/console-2026-02-16T19-06-21-167Z.log +31 -0
- package/README.md +2 -1
- package/dashboard-chat.png +0 -0
- package/dashboard-followups.png +0 -0
- package/dashboard-history.png +0 -0
- package/dashboard-integrations.png +0 -0
- package/dashboard-knowledge-ok.png +0 -0
- package/dashboard-knowledge.png +0 -0
- package/dashboard-notes-add.png +0 -0
- package/dashboard-notes-improved.png +0 -0
- package/dashboard-notes.png +0 -0
- package/dashboard-overview.png +0 -0
- package/dashboard-preferences.png +0 -0
- package/dashboard-settings-fixed.png +0 -0
- package/dashboard-settings.png +0 -0
- package/dashboard-skills-ok.png +0 -0
- package/dashboard-skills.png +0 -0
- package/dashboard-todos-add.png +0 -0
- package/dashboard-todos-improved.png +0 -0
- package/dashboard-todos-item.png +0 -0
- package/dashboard-todos-priority-badge.png +0 -0
- package/dashboard-todos.png +0 -0
- package/dashboard-topics.png +0 -0
- package/docs/ROADMAP.md +12 -49
- package/migrations/024_personal_features.sql +96 -0
- package/package.json +1 -1
- package/site/docs/index.html +11 -0
- package/site/docs/skills.html +107 -0
- package/site/index.html +9 -1
- package/src/agent/context.ts +3 -3
- package/src/agent/delegate.ts +7 -2
- package/src/agent/loop.ts +6 -6
- package/src/agent/prompts.ts +49 -1
- package/src/agent/workflow-executor.ts +5 -1
- package/src/channels/dashboard.html.ts +558 -6
- package/src/channels/webchat.ts +305 -27
- package/src/llm/claude-code.ts +58 -17
- package/src/llm/codex.ts +59 -18
- package/src/start.ts +12 -0
- package/src/tools/builtin/diagnose.ts +19 -5
- package/src/tools/builtin/follow-ups.ts +189 -0
- package/src/tools/builtin/notes.ts +207 -0
- package/src/tools/builtin/preferences.ts +173 -0
- package/src/tools/builtin/todos.ts +270 -0
- package/src/tools/builtin/topics.ts +166 -0
- package/src/tools/mcp-client.ts +8 -0
- package/src/tools/permissions.ts +7 -0
- package/tests/agent/session.test.ts +43 -45
- package/tests/mcp-registry.test.ts +32 -35
- package/tests/personal-features.test.ts +1251 -0
- package/tests/skill-registry.test.ts +1 -7
- package/tests/db/export.test.ts +0 -219
- package/tests/session.test.ts +0 -58
- package/tests/tools/executor.test.ts +0 -150
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { createTestDb } from "./helpers/test-db";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Test database
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
let testDb: Database;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create the test database with all migrations applied, then fix schema
|
|
13
|
+
* conflicts. Migration 008 creates a minimal `user_preferences` table.
|
|
14
|
+
* Migration 024 tries to create the expanded version but `IF NOT EXISTS`
|
|
15
|
+
* makes it a no-op. We drop and recreate with the schema that the
|
|
16
|
+
* preferences tool actually expects.
|
|
17
|
+
*/
|
|
18
|
+
function createPersonalFeaturesDb(): Database {
|
|
19
|
+
const db = createTestDb();
|
|
20
|
+
|
|
21
|
+
// Upgrade user_preferences to the 024 schema the tool expects
|
|
22
|
+
db.run("DROP TABLE IF EXISTS user_preferences");
|
|
23
|
+
db.run(`
|
|
24
|
+
CREATE TABLE user_preferences (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
category TEXT NOT NULL,
|
|
27
|
+
key TEXT NOT NULL,
|
|
28
|
+
value TEXT NOT NULL,
|
|
29
|
+
confidence REAL NOT NULL DEFAULT 0.8,
|
|
30
|
+
source TEXT NOT NULL DEFAULT 'inferred',
|
|
31
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
32
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
33
|
+
UNIQUE(category, key)
|
|
34
|
+
)
|
|
35
|
+
`);
|
|
36
|
+
db.run(
|
|
37
|
+
"CREATE INDEX IF NOT EXISTS idx_prefs_category ON user_preferences(category)"
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return db;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Mocks — must be declared BEFORE importing any module under test
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
mock.module("../src/db/connection", () => ({
|
|
48
|
+
getDb: () => testDb,
|
|
49
|
+
closeDb: () => {},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
mock.module("../src/util/logger", () => ({
|
|
53
|
+
logger: {
|
|
54
|
+
debug: () => {},
|
|
55
|
+
info: () => {},
|
|
56
|
+
warn: () => {},
|
|
57
|
+
error: () => {},
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
mock.module("../src/util/error-buffer", () => ({
|
|
62
|
+
recordError: () => {},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// Personal-feature tools are now registered as "auto" in the permissions map,
|
|
66
|
+
// so no mock needed here.
|
|
67
|
+
|
|
68
|
+
// Sandbox should never kick in for built-in tools
|
|
69
|
+
mock.module("../src/tools/sandbox", () => ({
|
|
70
|
+
executeSandboxed: async () => "sandboxed-noop",
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// Follow-ups scheduler mocks
|
|
74
|
+
const cronJobsAdded: any[] = [];
|
|
75
|
+
const cronJobsRemoved: any[] = [];
|
|
76
|
+
|
|
77
|
+
mock.module("../src/scheduler/cron", () => ({
|
|
78
|
+
addCronJob: (...args: any[]) => {
|
|
79
|
+
cronJobsAdded.push(args);
|
|
80
|
+
},
|
|
81
|
+
removeCronJob: (...args: any[]) => {
|
|
82
|
+
cronJobsRemoved.push(args);
|
|
83
|
+
},
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
mock.module("../src/scheduler/natural-cron", () => ({
|
|
87
|
+
parseNaturalSchedule: (input: string) => {
|
|
88
|
+
if (input.includes("invalid")) return "Could not parse schedule";
|
|
89
|
+
return { cron: new Date(Date.now() + 3_600_000).toISOString(), once: true };
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Import modules under test AFTER mocks are set up
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
const { registerTodosTool } = await import("../src/tools/builtin/todos");
|
|
98
|
+
const { registerNotesTool } = await import("../src/tools/builtin/notes");
|
|
99
|
+
const {
|
|
100
|
+
registerPreferencesTool,
|
|
101
|
+
loadPreferencesContext,
|
|
102
|
+
extractPreferences,
|
|
103
|
+
} = await import("../src/tools/builtin/preferences");
|
|
104
|
+
const {
|
|
105
|
+
registerTopicsTool,
|
|
106
|
+
getActiveTopic,
|
|
107
|
+
getTopicSessionId,
|
|
108
|
+
} = await import("../src/tools/builtin/topics");
|
|
109
|
+
const { registerFollowUpsTool } = await import(
|
|
110
|
+
"../src/tools/builtin/follow-ups"
|
|
111
|
+
);
|
|
112
|
+
const { executeTool } = await import("../src/tools/executor");
|
|
113
|
+
const { getTool, unregisterTool } = await import("../src/tools/registry");
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Helper: call a tool's execute fn directly (faster, no permission layer)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
async function callTool(
|
|
120
|
+
name: string,
|
|
121
|
+
input: Record<string, unknown>
|
|
122
|
+
): Promise<string> {
|
|
123
|
+
const tool = getTool(name);
|
|
124
|
+
if (!tool) throw new Error(`Tool "${name}" not registered`);
|
|
125
|
+
return tool.execute(input);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Register all tools once — the DB reference is swapped in beforeEach.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
registerTodosTool();
|
|
133
|
+
registerNotesTool();
|
|
134
|
+
registerPreferencesTool();
|
|
135
|
+
registerTopicsTool();
|
|
136
|
+
|
|
137
|
+
// follow-ups needs the db instance + stubs for router/config/llm
|
|
138
|
+
const fakeRouter = {} as any;
|
|
139
|
+
const fakeConfig = {} as any;
|
|
140
|
+
|
|
141
|
+
// We'll register follow-ups after first DB creation (it captures the db ref)
|
|
142
|
+
let followUpsRegistered = false;
|
|
143
|
+
|
|
144
|
+
function ensureFollowUps() {
|
|
145
|
+
if (!followUpsRegistered) {
|
|
146
|
+
registerFollowUpsTool(testDb, fakeRouter, fakeConfig);
|
|
147
|
+
followUpsRegistered = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Reset state before every test
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
testDb = createPersonalFeaturesDb();
|
|
157
|
+
cronJobsAdded.length = 0;
|
|
158
|
+
cronJobsRemoved.length = 0;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterAll(() => {
|
|
162
|
+
// Clean up registered tools to avoid polluting other test files
|
|
163
|
+
unregisterTool("todos");
|
|
164
|
+
unregisterTool("notes");
|
|
165
|
+
unregisterTool("preferences");
|
|
166
|
+
unregisterTool("topics");
|
|
167
|
+
unregisterTool("follow_ups");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ===========================================================================
|
|
171
|
+
// TODOS
|
|
172
|
+
// ===========================================================================
|
|
173
|
+
|
|
174
|
+
describe("todos tool", () => {
|
|
175
|
+
// --- add ---
|
|
176
|
+
|
|
177
|
+
test("add with title only returns success with ID", async () => {
|
|
178
|
+
const result = await callTool("todos", { action: "add", title: "Buy milk" });
|
|
179
|
+
expect(result).toContain("Added:");
|
|
180
|
+
expect(result).toContain("#1");
|
|
181
|
+
expect(result).toContain("Buy milk");
|
|
182
|
+
expect(result).toContain("[medium]"); // default priority
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("add with priority, due_date, and tags returns full details", async () => {
|
|
186
|
+
const result = await callTool("todos", {
|
|
187
|
+
action: "add",
|
|
188
|
+
title: "Ship feature",
|
|
189
|
+
priority: "high",
|
|
190
|
+
due_date: "2099-12-31",
|
|
191
|
+
tags: "work, release",
|
|
192
|
+
});
|
|
193
|
+
expect(result).toContain("Added:");
|
|
194
|
+
expect(result).toContain("Ship feature");
|
|
195
|
+
expect(result).toContain("[high]");
|
|
196
|
+
expect(result).toContain("due:");
|
|
197
|
+
// 2099-12-31 is far in the future — should show the raw date
|
|
198
|
+
expect(result).toContain("2099-12-31");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("add without title returns error", async () => {
|
|
202
|
+
const result = await callTool("todos", { action: "add" });
|
|
203
|
+
expect(result).toContain("Error");
|
|
204
|
+
expect(result).toContain("title is required");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// --- list ---
|
|
208
|
+
|
|
209
|
+
test("list pending returns correct items", async () => {
|
|
210
|
+
await callTool("todos", { action: "add", title: "Task A", priority: "high" });
|
|
211
|
+
await callTool("todos", { action: "add", title: "Task B", priority: "low" });
|
|
212
|
+
|
|
213
|
+
const result = await callTool("todos", { action: "list" });
|
|
214
|
+
expect(result).toContain("Todos (pending");
|
|
215
|
+
expect(result).toContain("2 items");
|
|
216
|
+
expect(result).toContain("Task A");
|
|
217
|
+
expect(result).toContain("Task B");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("list pending excludes completed items", async () => {
|
|
221
|
+
await callTool("todos", { action: "add", title: "Done task" });
|
|
222
|
+
await callTool("todos", { action: "complete", id: 1 });
|
|
223
|
+
await callTool("todos", { action: "add", title: "Pending task" });
|
|
224
|
+
|
|
225
|
+
const result = await callTool("todos", { action: "list", filter: "pending" });
|
|
226
|
+
expect(result).toContain("Pending task");
|
|
227
|
+
expect(result).not.toContain("Done task");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("list all shows both pending and done", async () => {
|
|
231
|
+
await callTool("todos", { action: "add", title: "Item 1" });
|
|
232
|
+
await callTool("todos", { action: "complete", id: 1 });
|
|
233
|
+
await callTool("todos", { action: "add", title: "Item 2" });
|
|
234
|
+
|
|
235
|
+
const result = await callTool("todos", { action: "list", filter: "all" });
|
|
236
|
+
expect(result).toContain("Item 1");
|
|
237
|
+
expect(result).toContain("Item 2");
|
|
238
|
+
expect(result).toContain("[done]");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("list overdue filters correctly", async () => {
|
|
242
|
+
// Insert a todo with a past due date directly
|
|
243
|
+
testDb
|
|
244
|
+
.prepare(
|
|
245
|
+
"INSERT INTO todos (title, priority, due_date) VALUES (?, ?, ?)"
|
|
246
|
+
)
|
|
247
|
+
.run("Overdue task", "medium", "2020-01-01");
|
|
248
|
+
// And one with a future due date
|
|
249
|
+
testDb
|
|
250
|
+
.prepare(
|
|
251
|
+
"INSERT INTO todos (title, priority, due_date) VALUES (?, ?, ?)"
|
|
252
|
+
)
|
|
253
|
+
.run("Future task", "medium", "2099-12-31");
|
|
254
|
+
|
|
255
|
+
const result = await callTool("todos", { action: "list", filter: "overdue" });
|
|
256
|
+
expect(result).toContain("Overdue task");
|
|
257
|
+
expect(result).not.toContain("Future task");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("list returns empty message when no todos match", async () => {
|
|
261
|
+
const result = await callTool("todos", { action: "list" });
|
|
262
|
+
expect(result).toContain("No pending todos found");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// --- complete ---
|
|
266
|
+
|
|
267
|
+
test("complete marks todo as done", async () => {
|
|
268
|
+
await callTool("todos", { action: "add", title: "Finish report" });
|
|
269
|
+
const result = await callTool("todos", { action: "complete", id: 1 });
|
|
270
|
+
expect(result).toBe("Completed todo #1.");
|
|
271
|
+
|
|
272
|
+
// Verify in DB
|
|
273
|
+
const row = testDb
|
|
274
|
+
.query("SELECT status, completed_at FROM todos WHERE id = 1")
|
|
275
|
+
.get() as any;
|
|
276
|
+
expect(row.status).toBe("done");
|
|
277
|
+
expect(row.completed_at).toBeTruthy();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("complete with invalid id returns not found", async () => {
|
|
281
|
+
const result = await callTool("todos", { action: "complete", id: 999 });
|
|
282
|
+
expect(result).toContain("No todo found with id #999");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("complete without id returns error", async () => {
|
|
286
|
+
const result = await callTool("todos", { action: "complete" });
|
|
287
|
+
expect(result).toContain("Error");
|
|
288
|
+
expect(result).toContain("id is required");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// --- remove ---
|
|
292
|
+
|
|
293
|
+
test("remove deletes todo", async () => {
|
|
294
|
+
await callTool("todos", { action: "add", title: "Temp task" });
|
|
295
|
+
const result = await callTool("todos", { action: "remove", id: 1 });
|
|
296
|
+
expect(result).toBe("Removed todo #1.");
|
|
297
|
+
|
|
298
|
+
const count = testDb
|
|
299
|
+
.query("SELECT COUNT(*) as c FROM todos")
|
|
300
|
+
.get() as any;
|
|
301
|
+
expect(count.c).toBe(0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("remove with invalid id returns not found", async () => {
|
|
305
|
+
const result = await callTool("todos", { action: "remove", id: 999 });
|
|
306
|
+
expect(result).toContain("No todo found with id #999");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// --- update ---
|
|
310
|
+
|
|
311
|
+
test("update priority changes the priority", async () => {
|
|
312
|
+
await callTool("todos", { action: "add", title: "Update me", priority: "low" });
|
|
313
|
+
const result = await callTool("todos", {
|
|
314
|
+
action: "update",
|
|
315
|
+
id: 1,
|
|
316
|
+
priority: "urgent",
|
|
317
|
+
});
|
|
318
|
+
expect(result).toBe("Updated todo #1.");
|
|
319
|
+
|
|
320
|
+
const row = testDb
|
|
321
|
+
.query("SELECT priority FROM todos WHERE id = 1")
|
|
322
|
+
.get() as any;
|
|
323
|
+
expect(row.priority).toBe("urgent");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("update multiple fields at once", async () => {
|
|
327
|
+
await callTool("todos", { action: "add", title: "Old title" });
|
|
328
|
+
await callTool("todos", {
|
|
329
|
+
action: "update",
|
|
330
|
+
id: 1,
|
|
331
|
+
title: "New title",
|
|
332
|
+
tags: "updated, multi",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const row = testDb
|
|
336
|
+
.query("SELECT title, tags FROM todos WHERE id = 1")
|
|
337
|
+
.get() as any;
|
|
338
|
+
expect(row.title).toBe("New title");
|
|
339
|
+
const tags = JSON.parse(row.tags);
|
|
340
|
+
expect(tags).toContain("updated");
|
|
341
|
+
expect(tags).toContain("multi");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("update without fields returns error", async () => {
|
|
345
|
+
await callTool("todos", { action: "add", title: "No change" });
|
|
346
|
+
const result = await callTool("todos", { action: "update", id: 1 });
|
|
347
|
+
expect(result).toContain("Error");
|
|
348
|
+
expect(result).toContain("at least one field");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("update without id returns error", async () => {
|
|
352
|
+
const result = await callTool("todos", {
|
|
353
|
+
action: "update",
|
|
354
|
+
title: "No id",
|
|
355
|
+
});
|
|
356
|
+
expect(result).toContain("Error");
|
|
357
|
+
expect(result).toContain("id is required");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// --- unknown action ---
|
|
361
|
+
|
|
362
|
+
test("unknown action returns error message", async () => {
|
|
363
|
+
const result = await callTool("todos", { action: "explode" });
|
|
364
|
+
expect(result).toContain("Unknown action: explode");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// --- executeTool integration ---
|
|
368
|
+
|
|
369
|
+
test("executeTool returns proper ToolResult shape", async () => {
|
|
370
|
+
const result = await executeTool("todos", "tu-1", {
|
|
371
|
+
action: "add",
|
|
372
|
+
title: "Via executor",
|
|
373
|
+
});
|
|
374
|
+
expect(result.tool_use_id).toBe("tu-1");
|
|
375
|
+
expect(result.is_error).toBe(false);
|
|
376
|
+
expect(result.content).toContain("Added:");
|
|
377
|
+
expect(result.content).toContain("Via executor");
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ===========================================================================
|
|
382
|
+
// NOTES
|
|
383
|
+
// ===========================================================================
|
|
384
|
+
|
|
385
|
+
describe("notes tool", () => {
|
|
386
|
+
// --- save ---
|
|
387
|
+
|
|
388
|
+
test("save creates a note and returns its ID", async () => {
|
|
389
|
+
const result = await callTool("notes", {
|
|
390
|
+
action: "save",
|
|
391
|
+
title: "Meeting notes",
|
|
392
|
+
content: "Discussed roadmap for Q3.",
|
|
393
|
+
});
|
|
394
|
+
expect(result).toContain("Note saved:");
|
|
395
|
+
expect(result).toContain("#1");
|
|
396
|
+
expect(result).toContain("Meeting notes");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("save with tags stores tags correctly", async () => {
|
|
400
|
+
await callTool("notes", {
|
|
401
|
+
action: "save",
|
|
402
|
+
title: "Tagged note",
|
|
403
|
+
content: "Content here",
|
|
404
|
+
tags: "work, ideas",
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const row = testDb
|
|
408
|
+
.query("SELECT tags FROM notes WHERE id = 1")
|
|
409
|
+
.get() as any;
|
|
410
|
+
const tags = JSON.parse(row.tags);
|
|
411
|
+
expect(tags).toContain("work");
|
|
412
|
+
expect(tags).toContain("ideas");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("save without title returns error", async () => {
|
|
416
|
+
const result = await callTool("notes", {
|
|
417
|
+
action: "save",
|
|
418
|
+
content: "No title",
|
|
419
|
+
});
|
|
420
|
+
expect(result).toContain("Error");
|
|
421
|
+
expect(result).toContain("title is required");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("save without content returns error", async () => {
|
|
425
|
+
const result = await callTool("notes", {
|
|
426
|
+
action: "save",
|
|
427
|
+
title: "No content",
|
|
428
|
+
});
|
|
429
|
+
expect(result).toContain("Error");
|
|
430
|
+
expect(result).toContain("content is required");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// --- list ---
|
|
434
|
+
|
|
435
|
+
test("list returns notes sorted by pinned then updated_at", async () => {
|
|
436
|
+
await callTool("notes", {
|
|
437
|
+
action: "save",
|
|
438
|
+
title: "Note A",
|
|
439
|
+
content: "First note",
|
|
440
|
+
});
|
|
441
|
+
await callTool("notes", {
|
|
442
|
+
action: "save",
|
|
443
|
+
title: "Note B",
|
|
444
|
+
content: "Second note",
|
|
445
|
+
});
|
|
446
|
+
// Pin Note A
|
|
447
|
+
await callTool("notes", { action: "pin", id: 1 });
|
|
448
|
+
|
|
449
|
+
const result = await callTool("notes", { action: "list" });
|
|
450
|
+
expect(result).toContain("Notes (2 total)");
|
|
451
|
+
// Pinned note should appear first
|
|
452
|
+
const indexA = result.indexOf("Note A");
|
|
453
|
+
const indexB = result.indexOf("Note B");
|
|
454
|
+
expect(indexA).toBeLessThan(indexB);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("list returns empty message when no notes exist", async () => {
|
|
458
|
+
const result = await callTool("notes", { action: "list" });
|
|
459
|
+
expect(result).toBe("No notes found.");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("list respects limit parameter", async () => {
|
|
463
|
+
for (let i = 1; i <= 5; i++) {
|
|
464
|
+
await callTool("notes", {
|
|
465
|
+
action: "save",
|
|
466
|
+
title: `Note ${i}`,
|
|
467
|
+
content: `Content ${i}`,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const result = await callTool("notes", { action: "list", limit: 2 });
|
|
472
|
+
expect(result).toContain("2 total");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// --- search ---
|
|
476
|
+
|
|
477
|
+
test("search via FTS finds matching notes", async () => {
|
|
478
|
+
await callTool("notes", {
|
|
479
|
+
action: "save",
|
|
480
|
+
title: "TypeScript tips",
|
|
481
|
+
content: "Use strict mode for better type safety.",
|
|
482
|
+
});
|
|
483
|
+
await callTool("notes", {
|
|
484
|
+
action: "save",
|
|
485
|
+
title: "Grocery list",
|
|
486
|
+
content: "Eggs, milk, bread.",
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const result = await callTool("notes", {
|
|
490
|
+
action: "search",
|
|
491
|
+
query: "TypeScript",
|
|
492
|
+
});
|
|
493
|
+
expect(result).toContain("TypeScript tips");
|
|
494
|
+
expect(result).not.toContain("Grocery list");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("search with no results returns appropriate message", async () => {
|
|
498
|
+
await callTool("notes", {
|
|
499
|
+
action: "save",
|
|
500
|
+
title: "Unrelated",
|
|
501
|
+
content: "Nothing relevant here.",
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const result = await callTool("notes", {
|
|
505
|
+
action: "search",
|
|
506
|
+
query: "quantum",
|
|
507
|
+
});
|
|
508
|
+
expect(result).toContain('No notes found matching "quantum"');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("search without query returns error", async () => {
|
|
512
|
+
const result = await callTool("notes", { action: "search" });
|
|
513
|
+
expect(result).toContain("Error");
|
|
514
|
+
expect(result).toContain("query is required");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// --- update ---
|
|
518
|
+
|
|
519
|
+
test("update modifies note fields", async () => {
|
|
520
|
+
await callTool("notes", {
|
|
521
|
+
action: "save",
|
|
522
|
+
title: "Original",
|
|
523
|
+
content: "Original content",
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const result = await callTool("notes", {
|
|
527
|
+
action: "update",
|
|
528
|
+
id: 1,
|
|
529
|
+
title: "Updated title",
|
|
530
|
+
content: "Updated content",
|
|
531
|
+
});
|
|
532
|
+
expect(result).toBe("Updated note #1.");
|
|
533
|
+
|
|
534
|
+
const row = testDb
|
|
535
|
+
.query("SELECT title, content FROM notes WHERE id = 1")
|
|
536
|
+
.get() as any;
|
|
537
|
+
expect(row.title).toBe("Updated title");
|
|
538
|
+
expect(row.content).toBe("Updated content");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("update with invalid id returns not found", async () => {
|
|
542
|
+
const result = await callTool("notes", {
|
|
543
|
+
action: "update",
|
|
544
|
+
id: 999,
|
|
545
|
+
title: "Nope",
|
|
546
|
+
});
|
|
547
|
+
expect(result).toContain("No note found with id #999");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("update without any fields returns error", async () => {
|
|
551
|
+
await callTool("notes", {
|
|
552
|
+
action: "save",
|
|
553
|
+
title: "Test",
|
|
554
|
+
content: "Content",
|
|
555
|
+
});
|
|
556
|
+
const result = await callTool("notes", { action: "update", id: 1 });
|
|
557
|
+
expect(result).toContain("Error");
|
|
558
|
+
expect(result).toContain("at least one field");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// --- delete ---
|
|
562
|
+
|
|
563
|
+
test("delete removes the note", async () => {
|
|
564
|
+
await callTool("notes", {
|
|
565
|
+
action: "save",
|
|
566
|
+
title: "To delete",
|
|
567
|
+
content: "Gone soon",
|
|
568
|
+
});
|
|
569
|
+
const result = await callTool("notes", { action: "delete", id: 1 });
|
|
570
|
+
expect(result).toBe("Deleted note #1.");
|
|
571
|
+
|
|
572
|
+
const count = testDb
|
|
573
|
+
.query("SELECT COUNT(*) as c FROM notes")
|
|
574
|
+
.get() as any;
|
|
575
|
+
expect(count.c).toBe(0);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("delete with invalid id returns not found", async () => {
|
|
579
|
+
const result = await callTool("notes", { action: "delete", id: 999 });
|
|
580
|
+
expect(result).toContain("No note found with id #999");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// --- pin ---
|
|
584
|
+
|
|
585
|
+
test("pin toggles pin state on", async () => {
|
|
586
|
+
await callTool("notes", {
|
|
587
|
+
action: "save",
|
|
588
|
+
title: "Pin me",
|
|
589
|
+
content: "Important note",
|
|
590
|
+
});
|
|
591
|
+
const result = await callTool("notes", { action: "pin", id: 1 });
|
|
592
|
+
expect(result).toContain("Note #1 is now pinned");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("pin toggles pin state off (unpin)", async () => {
|
|
596
|
+
await callTool("notes", {
|
|
597
|
+
action: "save",
|
|
598
|
+
title: "Unpin me",
|
|
599
|
+
content: "Less important",
|
|
600
|
+
});
|
|
601
|
+
// Pin then unpin
|
|
602
|
+
await callTool("notes", { action: "pin", id: 1 });
|
|
603
|
+
const result = await callTool("notes", { action: "pin", id: 1 });
|
|
604
|
+
expect(result).toContain("Note #1 is now unpinned");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("pin with invalid id returns not found", async () => {
|
|
608
|
+
const result = await callTool("notes", { action: "pin", id: 999 });
|
|
609
|
+
expect(result).toContain("No note found with id #999");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// --- unknown action ---
|
|
613
|
+
|
|
614
|
+
test("unknown action returns error message", async () => {
|
|
615
|
+
const result = await callTool("notes", { action: "archive" });
|
|
616
|
+
expect(result).toContain("Unknown action: archive");
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// ===========================================================================
|
|
621
|
+
// PREFERENCES
|
|
622
|
+
// ===========================================================================
|
|
623
|
+
|
|
624
|
+
describe("preferences tool", () => {
|
|
625
|
+
// --- set ---
|
|
626
|
+
|
|
627
|
+
test("set stores a preference", async () => {
|
|
628
|
+
const result = await callTool("preferences", {
|
|
629
|
+
action: "set",
|
|
630
|
+
category: "food",
|
|
631
|
+
key: "coffee_order",
|
|
632
|
+
value: "oat milk latte",
|
|
633
|
+
});
|
|
634
|
+
expect(result).toBe("Preference saved: food/coffee_order = oat milk latte");
|
|
635
|
+
|
|
636
|
+
const row = testDb
|
|
637
|
+
.query(
|
|
638
|
+
"SELECT value, confidence, source FROM user_preferences WHERE category = ? AND key = ?"
|
|
639
|
+
)
|
|
640
|
+
.get("food", "coffee_order") as any;
|
|
641
|
+
expect(row.value).toBe("oat milk latte");
|
|
642
|
+
expect(row.confidence).toBe(0.9); // default
|
|
643
|
+
expect(row.source).toBe("explicit");
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("set with custom confidence stores it", async () => {
|
|
647
|
+
await callTool("preferences", {
|
|
648
|
+
action: "set",
|
|
649
|
+
category: "work",
|
|
650
|
+
key: "editor",
|
|
651
|
+
value: "VSCode",
|
|
652
|
+
confidence: 0.7,
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const row = testDb
|
|
656
|
+
.query(
|
|
657
|
+
"SELECT confidence FROM user_preferences WHERE category = ? AND key = ?"
|
|
658
|
+
)
|
|
659
|
+
.get("work", "editor") as any;
|
|
660
|
+
expect(row.confidence).toBe(0.7);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("set overwrites existing preference (upsert)", async () => {
|
|
664
|
+
await callTool("preferences", {
|
|
665
|
+
action: "set",
|
|
666
|
+
category: "food",
|
|
667
|
+
key: "coffee_order",
|
|
668
|
+
value: "black coffee",
|
|
669
|
+
});
|
|
670
|
+
await callTool("preferences", {
|
|
671
|
+
action: "set",
|
|
672
|
+
category: "food",
|
|
673
|
+
key: "coffee_order",
|
|
674
|
+
value: "espresso",
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const row = testDb
|
|
678
|
+
.query(
|
|
679
|
+
"SELECT value FROM user_preferences WHERE category = ? AND key = ?"
|
|
680
|
+
)
|
|
681
|
+
.get("food", "coffee_order") as any;
|
|
682
|
+
expect(row.value).toBe("espresso");
|
|
683
|
+
|
|
684
|
+
// Ensure only one row exists
|
|
685
|
+
const count = testDb
|
|
686
|
+
.query("SELECT COUNT(*) as c FROM user_preferences")
|
|
687
|
+
.get() as any;
|
|
688
|
+
expect(count.c).toBe(1);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("set without required fields returns error", async () => {
|
|
692
|
+
const result = await callTool("preferences", { action: "set" });
|
|
693
|
+
expect(result).toContain("Error");
|
|
694
|
+
expect(result).toContain("category, key, and value are required");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// --- get ---
|
|
698
|
+
|
|
699
|
+
test("get retrieves a stored preference", async () => {
|
|
700
|
+
await callTool("preferences", {
|
|
701
|
+
action: "set",
|
|
702
|
+
category: "coding",
|
|
703
|
+
key: "language",
|
|
704
|
+
value: "TypeScript",
|
|
705
|
+
});
|
|
706
|
+
const result = await callTool("preferences", {
|
|
707
|
+
action: "get",
|
|
708
|
+
category: "coding",
|
|
709
|
+
key: "language",
|
|
710
|
+
});
|
|
711
|
+
expect(result).toBe("TypeScript");
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("get with nonexistent key returns not found", async () => {
|
|
715
|
+
const result = await callTool("preferences", {
|
|
716
|
+
action: "get",
|
|
717
|
+
category: "nope",
|
|
718
|
+
key: "nada",
|
|
719
|
+
});
|
|
720
|
+
expect(result).toBe("No preference found.");
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("get without category or key returns error", async () => {
|
|
724
|
+
const result = await callTool("preferences", { action: "get" });
|
|
725
|
+
expect(result).toContain("Error");
|
|
726
|
+
expect(result).toContain("category and key are required");
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// --- list ---
|
|
730
|
+
|
|
731
|
+
test("list shows all preferences grouped by category", async () => {
|
|
732
|
+
await callTool("preferences", {
|
|
733
|
+
action: "set",
|
|
734
|
+
category: "coding",
|
|
735
|
+
key: "language",
|
|
736
|
+
value: "TypeScript",
|
|
737
|
+
});
|
|
738
|
+
await callTool("preferences", {
|
|
739
|
+
action: "set",
|
|
740
|
+
category: "coding",
|
|
741
|
+
key: "editor",
|
|
742
|
+
value: "Neovim",
|
|
743
|
+
});
|
|
744
|
+
await callTool("preferences", {
|
|
745
|
+
action: "set",
|
|
746
|
+
category: "food",
|
|
747
|
+
key: "cuisine",
|
|
748
|
+
value: "Japanese",
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const result = await callTool("preferences", { action: "list" });
|
|
752
|
+
expect(result).toContain("## Coding");
|
|
753
|
+
expect(result).toContain("## Food");
|
|
754
|
+
expect(result).toContain("language: TypeScript");
|
|
755
|
+
expect(result).toContain("editor: Neovim");
|
|
756
|
+
expect(result).toContain("cuisine: Japanese");
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("list with no preferences returns empty message", async () => {
|
|
760
|
+
const result = await callTool("preferences", { action: "list" });
|
|
761
|
+
expect(result).toBe("No preferences saved yet.");
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// --- remove ---
|
|
765
|
+
|
|
766
|
+
test("remove deletes a preference", async () => {
|
|
767
|
+
await callTool("preferences", {
|
|
768
|
+
action: "set",
|
|
769
|
+
category: "food",
|
|
770
|
+
key: "drink",
|
|
771
|
+
value: "water",
|
|
772
|
+
});
|
|
773
|
+
const result = await callTool("preferences", {
|
|
774
|
+
action: "remove",
|
|
775
|
+
category: "food",
|
|
776
|
+
key: "drink",
|
|
777
|
+
});
|
|
778
|
+
expect(result).toBe("Preference removed: food/drink");
|
|
779
|
+
|
|
780
|
+
const count = testDb
|
|
781
|
+
.query("SELECT COUNT(*) as c FROM user_preferences")
|
|
782
|
+
.get() as any;
|
|
783
|
+
expect(count.c).toBe(0);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("remove nonexistent preference returns not found", async () => {
|
|
787
|
+
const result = await callTool("preferences", {
|
|
788
|
+
action: "remove",
|
|
789
|
+
category: "nope",
|
|
790
|
+
key: "nada",
|
|
791
|
+
});
|
|
792
|
+
expect(result).toContain("No preference found for nope/nada");
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("remove without category/key returns error", async () => {
|
|
796
|
+
const result = await callTool("preferences", { action: "remove" });
|
|
797
|
+
expect(result).toContain("Error");
|
|
798
|
+
expect(result).toContain("category and key are required");
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// --- unknown action ---
|
|
802
|
+
|
|
803
|
+
test("unknown action returns error message", async () => {
|
|
804
|
+
const result = await callTool("preferences", { action: "reset" });
|
|
805
|
+
expect(result).toContain("Unknown action: reset");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// --- loadPreferencesContext ---
|
|
809
|
+
|
|
810
|
+
test("loadPreferencesContext formats preferences for system prompt", async () => {
|
|
811
|
+
await callTool("preferences", {
|
|
812
|
+
action: "set",
|
|
813
|
+
category: "communication",
|
|
814
|
+
key: "tone",
|
|
815
|
+
value: "casual",
|
|
816
|
+
});
|
|
817
|
+
await callTool("preferences", {
|
|
818
|
+
action: "set",
|
|
819
|
+
category: "communication",
|
|
820
|
+
key: "language",
|
|
821
|
+
value: "English",
|
|
822
|
+
});
|
|
823
|
+
await callTool("preferences", {
|
|
824
|
+
action: "set",
|
|
825
|
+
category: "work",
|
|
826
|
+
key: "role",
|
|
827
|
+
value: "engineer",
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
const context = await loadPreferencesContext();
|
|
831
|
+
expect(context).toContain("## User preferences");
|
|
832
|
+
expect(context).toContain("**communication:**");
|
|
833
|
+
expect(context).toContain("- tone: casual");
|
|
834
|
+
expect(context).toContain("- language: English");
|
|
835
|
+
expect(context).toContain("**work:**");
|
|
836
|
+
expect(context).toContain("- role: engineer");
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test("loadPreferencesContext returns empty string when no prefs exist", async () => {
|
|
840
|
+
const context = await loadPreferencesContext();
|
|
841
|
+
expect(context).toBe("");
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
// --- extractPreferences ---
|
|
845
|
+
|
|
846
|
+
test("extractPreferences returns empty array (conservative design)", () => {
|
|
847
|
+
// The current implementation is intentionally conservative and returns []
|
|
848
|
+
const result = extractPreferences("I prefer TypeScript over JavaScript");
|
|
849
|
+
expect(Array.isArray(result)).toBe(true);
|
|
850
|
+
expect(result.length).toBe(0);
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// ===========================================================================
|
|
855
|
+
// TOPICS
|
|
856
|
+
// ===========================================================================
|
|
857
|
+
|
|
858
|
+
describe("topics tool", () => {
|
|
859
|
+
// Reset the module-level activeTopic between describes by switching to
|
|
860
|
+
// "current" and checking state. We cannot directly reset the module var,
|
|
861
|
+
// but we can archive any active topic.
|
|
862
|
+
|
|
863
|
+
// --- create ---
|
|
864
|
+
|
|
865
|
+
test("create inserts a new topic", async () => {
|
|
866
|
+
const result = await callTool("topics", {
|
|
867
|
+
action: "create",
|
|
868
|
+
name: "trip planning",
|
|
869
|
+
description: "Summer vacation",
|
|
870
|
+
});
|
|
871
|
+
expect(result).toContain("Topic 'trip planning' created");
|
|
872
|
+
|
|
873
|
+
const row = testDb
|
|
874
|
+
.query("SELECT * FROM conversation_topics WHERE name = ?")
|
|
875
|
+
.get("trip planning") as any;
|
|
876
|
+
expect(row).toBeTruthy();
|
|
877
|
+
expect(row.description).toBe("Summer vacation");
|
|
878
|
+
expect(row.status).toBe("active");
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test("create without name returns error", async () => {
|
|
882
|
+
const result = await callTool("topics", { action: "create" });
|
|
883
|
+
expect(result).toContain("Error");
|
|
884
|
+
expect(result).toContain("'name' is required");
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// --- switch ---
|
|
888
|
+
|
|
889
|
+
test("switch sets the active topic", async () => {
|
|
890
|
+
await callTool("topics", {
|
|
891
|
+
action: "create",
|
|
892
|
+
name: "work project",
|
|
893
|
+
});
|
|
894
|
+
const result = await callTool("topics", {
|
|
895
|
+
action: "switch",
|
|
896
|
+
name: "work project",
|
|
897
|
+
});
|
|
898
|
+
expect(result).toContain("Switched to topic: work project");
|
|
899
|
+
|
|
900
|
+
// Verify the module-level state
|
|
901
|
+
expect(getActiveTopic()).toBe("work project");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("switch to nonexistent topic auto-creates it", async () => {
|
|
905
|
+
const result = await callTool("topics", {
|
|
906
|
+
action: "switch",
|
|
907
|
+
name: "new topic",
|
|
908
|
+
});
|
|
909
|
+
expect(result).toContain("Switched to topic: new topic");
|
|
910
|
+
|
|
911
|
+
const row = testDb
|
|
912
|
+
.query("SELECT * FROM conversation_topics WHERE name = ?")
|
|
913
|
+
.get("new topic") as any;
|
|
914
|
+
expect(row).toBeTruthy();
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test("switch without name returns error", async () => {
|
|
918
|
+
const result = await callTool("topics", { action: "switch" });
|
|
919
|
+
expect(result).toContain("Error");
|
|
920
|
+
expect(result).toContain("'name' is required");
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// --- list ---
|
|
924
|
+
|
|
925
|
+
test("list shows active topics", async () => {
|
|
926
|
+
await callTool("topics", { action: "create", name: "alpha" });
|
|
927
|
+
await callTool("topics", { action: "create", name: "beta" });
|
|
928
|
+
|
|
929
|
+
const result = await callTool("topics", { action: "list" });
|
|
930
|
+
expect(result).toContain("Active topics:");
|
|
931
|
+
expect(result).toContain("alpha");
|
|
932
|
+
expect(result).toContain("beta");
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test("list shows current topic", async () => {
|
|
936
|
+
await callTool("topics", { action: "create", name: "focus" });
|
|
937
|
+
await callTool("topics", { action: "switch", name: "focus" });
|
|
938
|
+
|
|
939
|
+
const result = await callTool("topics", { action: "list" });
|
|
940
|
+
expect(result).toContain("Current: focus");
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
test("list with no topics returns helpful message", async () => {
|
|
944
|
+
// Reset active topic by creating + archiving it in the fresh DB
|
|
945
|
+
const current = getActiveTopic();
|
|
946
|
+
if (current) {
|
|
947
|
+
await callTool("topics", { action: "create", name: current });
|
|
948
|
+
await callTool("topics", { action: "archive", name: current });
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const result = await callTool("topics", { action: "list" });
|
|
952
|
+
expect(result).toContain("No active topics yet");
|
|
953
|
+
expect(result).toContain("Create one");
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
// --- archive ---
|
|
957
|
+
|
|
958
|
+
test("archive hides a topic", async () => {
|
|
959
|
+
await callTool("topics", { action: "create", name: "old project" });
|
|
960
|
+
const result = await callTool("topics", {
|
|
961
|
+
action: "archive",
|
|
962
|
+
name: "old project",
|
|
963
|
+
});
|
|
964
|
+
expect(result).toContain("Topic 'old project' archived");
|
|
965
|
+
|
|
966
|
+
const row = testDb
|
|
967
|
+
.query("SELECT status FROM conversation_topics WHERE name = ?")
|
|
968
|
+
.get("old project") as any;
|
|
969
|
+
expect(row.status).toBe("archived");
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test("archive resets activeTopic if it was the active one", async () => {
|
|
973
|
+
await callTool("topics", { action: "create", name: "temp" });
|
|
974
|
+
await callTool("topics", { action: "switch", name: "temp" });
|
|
975
|
+
expect(getActiveTopic()).toBe("temp");
|
|
976
|
+
|
|
977
|
+
await callTool("topics", { action: "archive", name: "temp" });
|
|
978
|
+
expect(getActiveTopic()).toBeNull();
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test("archive nonexistent topic returns not found", async () => {
|
|
982
|
+
const result = await callTool("topics", {
|
|
983
|
+
action: "archive",
|
|
984
|
+
name: "ghost",
|
|
985
|
+
});
|
|
986
|
+
expect(result).toContain("No active topic found with name 'ghost'");
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test("archive without name returns error", async () => {
|
|
990
|
+
const result = await callTool("topics", { action: "archive" });
|
|
991
|
+
expect(result).toContain("Error");
|
|
992
|
+
expect(result).toContain("'name' is required");
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// --- current ---
|
|
996
|
+
|
|
997
|
+
test("current returns active topic name", async () => {
|
|
998
|
+
await callTool("topics", { action: "create", name: "my topic" });
|
|
999
|
+
await callTool("topics", { action: "switch", name: "my topic" });
|
|
1000
|
+
|
|
1001
|
+
const result = await callTool("topics", { action: "current" });
|
|
1002
|
+
expect(result).toBe("my topic");
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test("current returns default when no topic selected", async () => {
|
|
1006
|
+
// Ensure any stale activeTopic from previous tests is cleared.
|
|
1007
|
+
// We must create the topic in the fresh DB before we can archive it.
|
|
1008
|
+
const current = getActiveTopic();
|
|
1009
|
+
if (current) {
|
|
1010
|
+
await callTool("topics", { action: "create", name: current });
|
|
1011
|
+
await callTool("topics", { action: "archive", name: current });
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const result = await callTool("topics", { action: "current" });
|
|
1015
|
+
expect(result).toContain("default");
|
|
1016
|
+
expect(result).toContain("no topic selected");
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
// --- unknown action ---
|
|
1020
|
+
|
|
1021
|
+
test("unknown action returns error message", async () => {
|
|
1022
|
+
const result = await callTool("topics", { action: "rename" });
|
|
1023
|
+
expect(result).toContain("Unknown action: rename");
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// --- getActiveTopic / getTopicSessionId ---
|
|
1027
|
+
|
|
1028
|
+
test("getActiveTopic returns null when no topic set", async () => {
|
|
1029
|
+
const current = getActiveTopic();
|
|
1030
|
+
if (current) {
|
|
1031
|
+
await callTool("topics", { action: "create", name: current });
|
|
1032
|
+
await callTool("topics", { action: "archive", name: current });
|
|
1033
|
+
}
|
|
1034
|
+
expect(getActiveTopic()).toBeNull();
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("getActiveTopic returns topic name after switch", async () => {
|
|
1038
|
+
await callTool("topics", { action: "create", name: "session test" });
|
|
1039
|
+
await callTool("topics", { action: "switch", name: "session test" });
|
|
1040
|
+
expect(getActiveTopic()).toBe("session test");
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
test("getTopicSessionId returns 'owner' when no topic", async () => {
|
|
1044
|
+
const current = getActiveTopic();
|
|
1045
|
+
if (current) {
|
|
1046
|
+
await callTool("topics", { action: "create", name: current });
|
|
1047
|
+
await callTool("topics", { action: "archive", name: current });
|
|
1048
|
+
}
|
|
1049
|
+
expect(getTopicSessionId()).toBe("owner");
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
test("getTopicSessionId returns sanitized ID for active topic", async () => {
|
|
1053
|
+
await callTool("topics", { action: "create", name: "Work Project!" });
|
|
1054
|
+
await callTool("topics", { action: "switch", name: "Work Project!" });
|
|
1055
|
+
const sessionId = getTopicSessionId();
|
|
1056
|
+
expect(sessionId).toBe("topic-work-project");
|
|
1057
|
+
// Should not contain special characters
|
|
1058
|
+
expect(sessionId).not.toContain("!");
|
|
1059
|
+
expect(sessionId).not.toContain(" ");
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("getTopicSessionId handles leading/trailing special chars", async () => {
|
|
1063
|
+
await callTool("topics", { action: "create", name: "---test---" });
|
|
1064
|
+
await callTool("topics", { action: "switch", name: "---test---" });
|
|
1065
|
+
const sessionId = getTopicSessionId();
|
|
1066
|
+
expect(sessionId).toBe("topic-test");
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
// ===========================================================================
|
|
1071
|
+
// FOLLOW-UPS
|
|
1072
|
+
// ===========================================================================
|
|
1073
|
+
|
|
1074
|
+
describe("follow_ups tool", () => {
|
|
1075
|
+
// follow-ups captures the db reference at registration time, so we need
|
|
1076
|
+
// to re-register for each test with the fresh testDb. We'll use the
|
|
1077
|
+
// callTool helper which goes through the registry.
|
|
1078
|
+
|
|
1079
|
+
beforeEach(() => {
|
|
1080
|
+
// Re-register follow-ups with the new testDb
|
|
1081
|
+
unregisterTool("follow_ups");
|
|
1082
|
+
registerFollowUpsTool(testDb, fakeRouter, fakeConfig);
|
|
1083
|
+
followUpsRegistered = true;
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
// --- schedule ---
|
|
1087
|
+
|
|
1088
|
+
test("schedule creates a follow-up and cron job", async () => {
|
|
1089
|
+
const result = await callTool("follow_ups", {
|
|
1090
|
+
action: "schedule",
|
|
1091
|
+
context: "dentist appointment",
|
|
1092
|
+
message: "How did the dentist go?",
|
|
1093
|
+
delay: "in 2 hours",
|
|
1094
|
+
});
|
|
1095
|
+
expect(result).toContain("Follow-up scheduled");
|
|
1096
|
+
expect(result).toContain("How did the dentist go?");
|
|
1097
|
+
|
|
1098
|
+
// Verify DB record
|
|
1099
|
+
const row = testDb
|
|
1100
|
+
.query("SELECT * FROM follow_ups WHERE id = 1")
|
|
1101
|
+
.get() as any;
|
|
1102
|
+
expect(row).toBeTruthy();
|
|
1103
|
+
expect(row.context).toBe("dentist appointment");
|
|
1104
|
+
expect(row.message).toBe("How did the dentist go?");
|
|
1105
|
+
expect(row.status).toBe("pending");
|
|
1106
|
+
|
|
1107
|
+
// Verify cron job was added
|
|
1108
|
+
expect(cronJobsAdded.length).toBe(1);
|
|
1109
|
+
expect(cronJobsAdded[0][1]).toBe("followup-1"); // cronName
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test("schedule adds 'in' prefix if missing from delay", async () => {
|
|
1113
|
+
await callTool("follow_ups", {
|
|
1114
|
+
action: "schedule",
|
|
1115
|
+
context: "interview",
|
|
1116
|
+
message: "How did the interview go?",
|
|
1117
|
+
delay: "2 hours",
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
// Should still succeed (code prepends "in " if missing)
|
|
1121
|
+
expect(cronJobsAdded.length).toBe(1);
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
test("schedule without required fields returns error", async () => {
|
|
1125
|
+
const result = await callTool("follow_ups", { action: "schedule" });
|
|
1126
|
+
expect(result).toContain("Error");
|
|
1127
|
+
expect(result).toContain("context, message, and delay are required");
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
test("schedule with unparseable delay returns error", async () => {
|
|
1131
|
+
const result = await callTool("follow_ups", {
|
|
1132
|
+
action: "schedule",
|
|
1133
|
+
context: "test",
|
|
1134
|
+
message: "test message",
|
|
1135
|
+
delay: "invalid nonsense",
|
|
1136
|
+
});
|
|
1137
|
+
expect(result).toContain("Error");
|
|
1138
|
+
expect(result).toContain("does not look like a one-shot delay");
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
// --- list ---
|
|
1142
|
+
|
|
1143
|
+
test("list shows pending follow-ups", async () => {
|
|
1144
|
+
await callTool("follow_ups", {
|
|
1145
|
+
action: "schedule",
|
|
1146
|
+
context: "doctor visit",
|
|
1147
|
+
message: "How was the doctor?",
|
|
1148
|
+
delay: "in 1 hour",
|
|
1149
|
+
});
|
|
1150
|
+
await callTool("follow_ups", {
|
|
1151
|
+
action: "schedule",
|
|
1152
|
+
context: "meeting",
|
|
1153
|
+
message: "How was the meeting?",
|
|
1154
|
+
delay: "in 3 hours",
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
const result = await callTool("follow_ups", { action: "list" });
|
|
1158
|
+
expect(result).toContain("doctor visit");
|
|
1159
|
+
expect(result).toContain("How was the doctor?");
|
|
1160
|
+
expect(result).toContain("meeting");
|
|
1161
|
+
expect(result).toContain("How was the meeting?");
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
test("list with no pending follow-ups returns message", async () => {
|
|
1165
|
+
const result = await callTool("follow_ups", { action: "list" });
|
|
1166
|
+
expect(result).toBe("No pending follow-ups.");
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
test("list excludes cancelled follow-ups", async () => {
|
|
1170
|
+
await callTool("follow_ups", {
|
|
1171
|
+
action: "schedule",
|
|
1172
|
+
context: "cancelled item",
|
|
1173
|
+
message: "Should not appear",
|
|
1174
|
+
delay: "in 1 hour",
|
|
1175
|
+
});
|
|
1176
|
+
await callTool("follow_ups", { action: "cancel", id: 1 });
|
|
1177
|
+
|
|
1178
|
+
const result = await callTool("follow_ups", { action: "list" });
|
|
1179
|
+
expect(result).toBe("No pending follow-ups.");
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
// --- cancel ---
|
|
1183
|
+
|
|
1184
|
+
test("cancel marks follow-up as cancelled and removes cron", async () => {
|
|
1185
|
+
await callTool("follow_ups", {
|
|
1186
|
+
action: "schedule",
|
|
1187
|
+
context: "something",
|
|
1188
|
+
message: "Ping",
|
|
1189
|
+
delay: "in 30 minutes",
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
cronJobsRemoved.length = 0;
|
|
1193
|
+
const result = await callTool("follow_ups", { action: "cancel", id: 1 });
|
|
1194
|
+
expect(result).toBe("Follow-up #1 cancelled.");
|
|
1195
|
+
|
|
1196
|
+
// Verify DB status
|
|
1197
|
+
const row = testDb
|
|
1198
|
+
.query("SELECT status FROM follow_ups WHERE id = 1")
|
|
1199
|
+
.get() as any;
|
|
1200
|
+
expect(row.status).toBe("cancelled");
|
|
1201
|
+
|
|
1202
|
+
// Verify cron removal
|
|
1203
|
+
expect(cronJobsRemoved.length).toBe(1);
|
|
1204
|
+
expect(cronJobsRemoved[0][1]).toBe("followup-1");
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("cancel without id returns error", async () => {
|
|
1208
|
+
const result = await callTool("follow_ups", { action: "cancel" });
|
|
1209
|
+
expect(result).toContain("Error");
|
|
1210
|
+
expect(result).toContain("id is required");
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
test("cancel with nonexistent id returns not found", async () => {
|
|
1214
|
+
const result = await callTool("follow_ups", { action: "cancel", id: 999 });
|
|
1215
|
+
expect(result).toContain("No pending follow-up found with ID 999");
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
test("cancel an already-cancelled follow-up returns not found", async () => {
|
|
1219
|
+
await callTool("follow_ups", {
|
|
1220
|
+
action: "schedule",
|
|
1221
|
+
context: "double cancel",
|
|
1222
|
+
message: "test",
|
|
1223
|
+
delay: "in 1 hour",
|
|
1224
|
+
});
|
|
1225
|
+
await callTool("follow_ups", { action: "cancel", id: 1 });
|
|
1226
|
+
|
|
1227
|
+
const result = await callTool("follow_ups", { action: "cancel", id: 1 });
|
|
1228
|
+
expect(result).toContain("No pending follow-up found with ID 1");
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
// --- unknown action ---
|
|
1232
|
+
|
|
1233
|
+
test("unknown action returns error message", async () => {
|
|
1234
|
+
const result = await callTool("follow_ups", { action: "snooze" });
|
|
1235
|
+
expect(result).toContain("Unknown action: snooze");
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
// --- executeTool integration ---
|
|
1239
|
+
|
|
1240
|
+
test("executeTool works for follow_ups schedule", async () => {
|
|
1241
|
+
const result = await executeTool("follow_ups", "fu-1", {
|
|
1242
|
+
action: "schedule",
|
|
1243
|
+
context: "gym session",
|
|
1244
|
+
message: "Did you work out?",
|
|
1245
|
+
delay: "in 4 hours",
|
|
1246
|
+
});
|
|
1247
|
+
expect(result.tool_use_id).toBe("fu-1");
|
|
1248
|
+
expect(result.is_error).toBe(false);
|
|
1249
|
+
expect(result.content).toContain("Follow-up scheduled");
|
|
1250
|
+
});
|
|
1251
|
+
});
|