@vellumai/assistant 0.11.0-staging.1 → 0.11.0-staging.2
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/openapi.yaml +18 -4
- package/package.json +1 -1
- package/src/__tests__/app-conversation-ids-backfill.test.ts +33 -2
- package/src/__tests__/app-conversation-ids.test.ts +204 -0
- package/src/__tests__/conversation-group-tools.test.ts +279 -0
- package/src/__tests__/conversation-lineage.test.ts +235 -0
- package/src/__tests__/platform-callback-registration.test.ts +10 -10
- package/src/__tests__/resolve-app-id.test.ts +32 -0
- package/src/__tests__/starter-task-flow.test.ts +24 -4
- package/src/__tests__/tool-approval-handler.test.ts +4 -0
- package/src/__tests__/tool-side-effects-slack-dm.test.ts +1 -0
- package/src/__tests__/workspace-policy.test.ts +83 -2
- package/src/api/responses/subagent-detail.ts +13 -0
- package/src/apps/app-store.ts +95 -4
- package/src/calls/__tests__/voice-session-bridge.test.ts +71 -3
- package/src/calls/voice-session-bridge.ts +22 -7
- package/src/cli/commands/webhooks.help.ts +9 -4
- package/src/config/bundled-skills/conversation-groups/SKILL.md +29 -0
- package/src/config/bundled-skills/conversation-groups/TOOLS.json +58 -0
- package/src/config/bundled-skills/conversation-groups/tools/group-create.ts +12 -0
- package/src/config/bundled-skills/conversation-groups/tools/group-list.ts +12 -0
- package/src/config/bundled-skills/conversation-groups/tools/move-to-group.ts +12 -0
- package/src/config/bundled-skills/schedule/SKILL.md +2 -0
- package/src/config/feature-flag-registry.json +0 -8
- package/src/daemon/conversation-lineage.ts +76 -0
- package/src/daemon/conversation-surfaces.ts +4 -10
- package/src/daemon/tool-side-effects.ts +27 -11
- package/src/live-voice/__tests__/live-voice-continuation-fork-hydration.test.ts +286 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +889 -167
- package/src/live-voice/live-voice-session.ts +733 -201
- package/src/permissions/checker.test.ts +213 -6
- package/src/permissions/checker.ts +85 -14
- package/src/permissions/workspace-policy.ts +51 -18
- package/src/persistence/conversation-parent.ts +27 -0
- package/src/persistence/migrations/354-backfill-app-conversation-lineage.ts +41 -0
- package/src/persistence/steps.ts +2 -0
- package/src/prompts/sections.ts +4 -16
- package/src/runtime/routes/__tests__/subagents-routes.test.ts +250 -0
- package/src/runtime/routes/__tests__/webhook-routes.test.ts +201 -0
- package/src/runtime/routes/subagents-routes.ts +35 -8
- package/src/runtime/routes/webhook-routes.ts +89 -30
- package/src/tools/__tests__/conversation-group-tool-input-schemas.test.ts +61 -0
- package/src/tools/apps/resolve-app-id.ts +15 -3
- package/src/tools/conversation-groups/group_create.ts +63 -0
- package/src/tools/conversation-groups/group_list.ts +20 -0
- package/src/tools/conversation-groups/group_shared.ts +52 -0
- package/src/tools/conversation-groups/move_to_group.ts +106 -0
- package/src/util/platform.ts +15 -0
- package/tsconfig.plugin-api.json +1 -1
package/openapi.yaml
CHANGED
|
@@ -29334,7 +29334,9 @@ paths:
|
|
|
29334
29334
|
required: false
|
|
29335
29335
|
schema:
|
|
29336
29336
|
type: string
|
|
29337
|
-
description:
|
|
29337
|
+
description:
|
|
29338
|
+
The subagent's own conversation ID. Fallback only — when the daemon knows the subagent (live or
|
|
29339
|
+
rehydrated), it resolves the conversation itself and this parameter is ignored.
|
|
29338
29340
|
responses:
|
|
29339
29341
|
"200":
|
|
29340
29342
|
description: Successful response
|
|
@@ -29397,6 +29399,10 @@ paths:
|
|
|
29397
29399
|
- type
|
|
29398
29400
|
- content
|
|
29399
29401
|
additionalProperties: false
|
|
29402
|
+
label:
|
|
29403
|
+
type: string
|
|
29404
|
+
parentToolUseId:
|
|
29405
|
+
type: string
|
|
29400
29406
|
required:
|
|
29401
29407
|
- subagentId
|
|
29402
29408
|
- events
|
|
@@ -29489,8 +29495,9 @@ paths:
|
|
|
29489
29495
|
operationId: subagents_reconcile_get
|
|
29490
29496
|
summary: Reconcile subagent live status
|
|
29491
29497
|
description:
|
|
29492
|
-
Returns the live in-memory status of all subagents known to the daemon for a given parent conversation
|
|
29493
|
-
|
|
29498
|
+
Returns the live in-memory status of all subagents known to the daemon for a given parent conversation,
|
|
29499
|
+
with the identity fields a client needs to rebuild a store entry it never saw spawn (label, the subagent's own
|
|
29500
|
+
conversation id, and the spawning tool-use id). Subagents not in the response are orphaned.
|
|
29494
29501
|
tags:
|
|
29495
29502
|
- subagents
|
|
29496
29503
|
parameters:
|
|
@@ -29517,6 +29524,12 @@ paths:
|
|
|
29517
29524
|
properties:
|
|
29518
29525
|
status:
|
|
29519
29526
|
type: string
|
|
29527
|
+
label:
|
|
29528
|
+
type: string
|
|
29529
|
+
conversationId:
|
|
29530
|
+
type: string
|
|
29531
|
+
parentToolUseId:
|
|
29532
|
+
type: string
|
|
29520
29533
|
required:
|
|
29521
29534
|
- status
|
|
29522
29535
|
additionalProperties: false
|
|
@@ -31536,7 +31549,8 @@ paths:
|
|
|
31536
31549
|
summary: Register a webhook callback URL
|
|
31537
31550
|
description:
|
|
31538
31551
|
Resolves a stable callback URL for a webhook type. On platform-managed assistants, registers the route with
|
|
31539
|
-
the platform gateway.
|
|
31552
|
+
the platform gateway. Otherwise uses the configured ingress.publicBaseUrl, falling back to the platform gateway
|
|
31553
|
+
when no ingress is configured and the assistant is connected to the platform.
|
|
31540
31554
|
tags:
|
|
31541
31555
|
- webhooks
|
|
31542
31556
|
requestBody:
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../apps/app-store.js";
|
|
12
12
|
import { getDb } from "../persistence/db-connection.js";
|
|
13
13
|
import { initializeDb } from "../persistence/db-init.js";
|
|
14
|
+
import { migrateBackfillAppConversationLineage } from "../persistence/migrations/354-backfill-app-conversation-lineage.js";
|
|
14
15
|
import { rawRun } from "../persistence/raw-query.js";
|
|
15
16
|
|
|
16
17
|
// Initialize db once for all tests
|
|
@@ -57,15 +58,18 @@ function insertMessage(
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** Insert a conversation row so FK constraints are satisfied. */
|
|
60
|
-
function insertConversation(id: string): void {
|
|
61
|
+
function insertConversation(id: string, parentConversationId?: string): void {
|
|
61
62
|
const now = Date.now();
|
|
62
63
|
rawRun(
|
|
63
64
|
"test:insertConversation",
|
|
64
|
-
`INSERT OR IGNORE INTO conversations (
|
|
65
|
+
`INSERT OR IGNORE INTO conversations (
|
|
66
|
+
id, title, created_at, updated_at, parent_conversation_id
|
|
67
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
65
68
|
id,
|
|
66
69
|
"test",
|
|
67
70
|
now,
|
|
68
71
|
now,
|
|
72
|
+
parentConversationId ?? null,
|
|
69
73
|
);
|
|
70
74
|
}
|
|
71
75
|
|
|
@@ -272,3 +276,30 @@ describe("backfillAppConversationIds", () => {
|
|
|
272
276
|
expect(loaded?.conversationIds).toEqual([existingConvId, backfillConvId]);
|
|
273
277
|
});
|
|
274
278
|
});
|
|
279
|
+
|
|
280
|
+
describe("migrateBackfillAppConversationLineage", () => {
|
|
281
|
+
test("adds inherited ancestors to existing direct app associations", () => {
|
|
282
|
+
const app = createApp(makeAppParams("Lineage App"));
|
|
283
|
+
const rootConversationId = "conv-lineage-root";
|
|
284
|
+
const parentConversationId = "conv-lineage-parent";
|
|
285
|
+
const childConversationId = "conv-lineage-child";
|
|
286
|
+
insertConversation(rootConversationId);
|
|
287
|
+
insertConversation(parentConversationId, rootConversationId);
|
|
288
|
+
insertConversation(childConversationId, parentConversationId);
|
|
289
|
+
addAppConversationId(app.id, childConversationId);
|
|
290
|
+
|
|
291
|
+
migrateBackfillAppConversationLineage();
|
|
292
|
+
migrateBackfillAppConversationLineage();
|
|
293
|
+
|
|
294
|
+
const loaded = getApp(app.id);
|
|
295
|
+
expect(loaded?.conversationIds).toEqual([
|
|
296
|
+
childConversationId,
|
|
297
|
+
parentConversationId,
|
|
298
|
+
rootConversationId,
|
|
299
|
+
]);
|
|
300
|
+
expect(loaded?.inheritedConversationIds).toEqual([
|
|
301
|
+
parentConversationId,
|
|
302
|
+
rootConversationId,
|
|
303
|
+
]);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -7,8 +7,16 @@ import {
|
|
|
7
7
|
addAppConversationId,
|
|
8
8
|
createApp,
|
|
9
9
|
getApp,
|
|
10
|
+
isDirectAppConversation,
|
|
11
|
+
linkAppToConversationLineage,
|
|
10
12
|
listAppsByConversation,
|
|
11
13
|
} from "../apps/app-store.js";
|
|
14
|
+
import {
|
|
15
|
+
deleteConversation,
|
|
16
|
+
removeSubagentConversation,
|
|
17
|
+
setConversation,
|
|
18
|
+
setSubagentConversation,
|
|
19
|
+
} from "../daemon/conversation-registry.js";
|
|
12
20
|
|
|
13
21
|
// ---------------------------------------------------------------------------
|
|
14
22
|
// Helpers
|
|
@@ -16,6 +24,41 @@ import {
|
|
|
16
24
|
|
|
17
25
|
let testDataDir: string;
|
|
18
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The registry stores live `Conversation` instances; lineage resolution only
|
|
29
|
+
* reads `parentConversationId`, so a minimal stand-in is enough.
|
|
30
|
+
*/
|
|
31
|
+
type RegistryConversation = Parameters<typeof setConversation>[1];
|
|
32
|
+
|
|
33
|
+
const registeredTopLevel: string[] = [];
|
|
34
|
+
const registeredSubagents: [string, RegistryConversation][] = [];
|
|
35
|
+
|
|
36
|
+
function stubConversation(parentConversationId?: string): RegistryConversation {
|
|
37
|
+
return { parentConversationId } as unknown as RegistryConversation;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Register a resident top-level conversation, optionally forked from a parent. */
|
|
41
|
+
function registerConversation(id: string, parentId?: string): void {
|
|
42
|
+
setConversation(id, stubConversation(parentId));
|
|
43
|
+
registeredTopLevel.push(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Register a live subagent conversation forked from `parentId`. */
|
|
47
|
+
function registerSubagent(id: string, parentId: string): void {
|
|
48
|
+
const conversation = stubConversation(parentId);
|
|
49
|
+
setSubagentConversation(id, conversation);
|
|
50
|
+
registeredSubagents.push([id, conversation]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function clearRegisteredConversations(): void {
|
|
54
|
+
for (const id of registeredTopLevel.splice(0)) {
|
|
55
|
+
deleteConversation(id);
|
|
56
|
+
}
|
|
57
|
+
for (const [id, conversation] of registeredSubagents.splice(0)) {
|
|
58
|
+
removeSubagentConversation(id, conversation);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
19
62
|
function freshTempDir(): string {
|
|
20
63
|
return join(
|
|
21
64
|
tmpdir(),
|
|
@@ -41,6 +84,7 @@ beforeEach(() => {
|
|
|
41
84
|
});
|
|
42
85
|
|
|
43
86
|
afterEach(() => {
|
|
87
|
+
clearRegisteredConversations();
|
|
44
88
|
if (existsSync(testDataDir)) {
|
|
45
89
|
rmSync(testDataDir, { recursive: true, force: true });
|
|
46
90
|
}
|
|
@@ -149,3 +193,163 @@ describe("listAppsByConversation", () => {
|
|
|
149
193
|
expect(result).toEqual([]);
|
|
150
194
|
});
|
|
151
195
|
});
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// linkAppToConversationLineage
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
describe("linkAppToConversationLineage", () => {
|
|
202
|
+
test("an app created in a forked conversation carries the fork and its parent", () => {
|
|
203
|
+
registerConversation("conv-visible");
|
|
204
|
+
registerSubagent("conv-fork", "conv-visible");
|
|
205
|
+
|
|
206
|
+
const app = createApp(makeAppParams("Continuation App"));
|
|
207
|
+
linkAppToConversationLineage(app.id, "conv-fork");
|
|
208
|
+
|
|
209
|
+
expect(getApp(app.id)?.conversationIds).toEqual([
|
|
210
|
+
"conv-fork",
|
|
211
|
+
"conv-visible",
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("the user-visible ancestor lists an app created by a background subagent", () => {
|
|
216
|
+
registerConversation("conv-visible");
|
|
217
|
+
registerSubagent("conv-fork", "conv-visible");
|
|
218
|
+
|
|
219
|
+
const app = createApp(makeAppParams("Continuation App"));
|
|
220
|
+
linkAppToConversationLineage(app.id, "conv-fork");
|
|
221
|
+
|
|
222
|
+
const visible = listAppsByConversation("conv-visible");
|
|
223
|
+
expect(visible).toHaveLength(1);
|
|
224
|
+
expect(visible[0].id).toBe(app.id);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("walks nested subagents up to the user-visible thread", () => {
|
|
228
|
+
registerConversation("conv-visible");
|
|
229
|
+
registerSubagent("conv-outer", "conv-visible");
|
|
230
|
+
registerSubagent("conv-inner", "conv-outer");
|
|
231
|
+
|
|
232
|
+
const app = createApp(makeAppParams("Nested App"));
|
|
233
|
+
linkAppToConversationLineage(app.id, "conv-inner");
|
|
234
|
+
|
|
235
|
+
expect(getApp(app.id)?.conversationIds).toEqual([
|
|
236
|
+
"conv-inner",
|
|
237
|
+
"conv-outer",
|
|
238
|
+
"conv-visible",
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("re-linking the same lineage is idempotent", () => {
|
|
243
|
+
registerConversation("conv-visible");
|
|
244
|
+
registerSubagent("conv-fork", "conv-visible");
|
|
245
|
+
|
|
246
|
+
const app = createApp(makeAppParams("Repeat App"));
|
|
247
|
+
linkAppToConversationLineage(app.id, "conv-fork");
|
|
248
|
+
linkAppToConversationLineage(app.id, "conv-fork");
|
|
249
|
+
|
|
250
|
+
expect(getApp(app.id)?.conversationIds).toEqual([
|
|
251
|
+
"conv-fork",
|
|
252
|
+
"conv-visible",
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("a lineage of one behaves exactly like addAppConversationId", () => {
|
|
257
|
+
registerConversation("conv-plain");
|
|
258
|
+
|
|
259
|
+
const linked = createApp(makeAppParams("Linked App"));
|
|
260
|
+
const direct = createApp(makeAppParams("Direct App"));
|
|
261
|
+
linkAppToConversationLineage(linked.id, "conv-plain");
|
|
262
|
+
addAppConversationId(direct.id, "conv-plain");
|
|
263
|
+
|
|
264
|
+
expect(getApp(linked.id)?.conversationIds).toEqual(["conv-plain"]);
|
|
265
|
+
expect(getApp(direct.id)?.conversationIds).toEqual(
|
|
266
|
+
getApp(linked.id)?.conversationIds,
|
|
267
|
+
);
|
|
268
|
+
expect(listAppsByConversation("conv-plain")).toHaveLength(2);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("an unregistered conversation links only itself", () => {
|
|
272
|
+
const app = createApp(makeAppParams("Orphan App"));
|
|
273
|
+
linkAppToConversationLineage(app.id, "conv-gone");
|
|
274
|
+
|
|
275
|
+
expect(getApp(app.id)?.conversationIds).toEqual(["conv-gone"]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a non-existent app is a no-op", () => {
|
|
279
|
+
registerConversation("conv-visible");
|
|
280
|
+
registerSubagent("conv-fork", "conv-visible");
|
|
281
|
+
|
|
282
|
+
expect(() =>
|
|
283
|
+
linkAppToConversationLineage("nonexistent-id", "conv-fork"),
|
|
284
|
+
).not.toThrow();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// Direct vs inherited associations
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
describe("isDirectAppConversation", () => {
|
|
293
|
+
test("the seed conversation is direct and its ancestors are inherited", () => {
|
|
294
|
+
registerConversation("conv-visible");
|
|
295
|
+
registerSubagent("conv-fork", "conv-visible");
|
|
296
|
+
|
|
297
|
+
const app = createApp(makeAppParams("Continuation App"));
|
|
298
|
+
linkAppToConversationLineage(app.id, "conv-fork");
|
|
299
|
+
|
|
300
|
+
const loaded = getApp(app.id)!;
|
|
301
|
+
expect(loaded.inheritedConversationIds).toEqual(["conv-visible"]);
|
|
302
|
+
expect(isDirectAppConversation(loaded, "conv-fork")).toBe(true);
|
|
303
|
+
expect(isDirectAppConversation(loaded, "conv-visible")).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("an inherited association still lists the app in the ancestor thread", () => {
|
|
307
|
+
registerConversation("conv-visible");
|
|
308
|
+
registerSubagent("conv-fork", "conv-visible");
|
|
309
|
+
|
|
310
|
+
const app = createApp(makeAppParams("Continuation App"));
|
|
311
|
+
linkAppToConversationLineage(app.id, "conv-fork");
|
|
312
|
+
|
|
313
|
+
expect(listAppsByConversation("conv-visible").map((a) => a.id)).toEqual([
|
|
314
|
+
app.id,
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("a record carrying no inherited list treats every association as direct", () => {
|
|
319
|
+
const app = createApp(makeAppParams("Plain App"));
|
|
320
|
+
addAppConversationId(app.id, "conv-plain");
|
|
321
|
+
|
|
322
|
+
const loaded = getApp(app.id)!;
|
|
323
|
+
expect(loaded.inheritedConversationIds).toBeUndefined();
|
|
324
|
+
expect(isDirectAppConversation(loaded, "conv-plain")).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("a direct association supersedes an earlier inherited one", () => {
|
|
328
|
+
registerConversation("conv-visible");
|
|
329
|
+
registerSubagent("conv-fork", "conv-visible");
|
|
330
|
+
|
|
331
|
+
const app = createApp(makeAppParams("Adopted App"));
|
|
332
|
+
linkAppToConversationLineage(app.id, "conv-fork");
|
|
333
|
+
// The user later works on the same app in the thread they can see.
|
|
334
|
+
expect(addAppConversationId(app.id, "conv-visible")).toBe(false);
|
|
335
|
+
|
|
336
|
+
const loaded = getApp(app.id)!;
|
|
337
|
+
expect(loaded.conversationIds).toEqual(["conv-fork", "conv-visible"]);
|
|
338
|
+
expect(loaded.inheritedConversationIds).toEqual([]);
|
|
339
|
+
expect(isDirectAppConversation(loaded, "conv-visible")).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("an inherited link never demotes an existing direct association", () => {
|
|
343
|
+
registerConversation("conv-visible");
|
|
344
|
+
registerSubagent("conv-fork", "conv-visible");
|
|
345
|
+
|
|
346
|
+
const app = createApp(makeAppParams("Foreground App"));
|
|
347
|
+
addAppConversationId(app.id, "conv-visible");
|
|
348
|
+
linkAppToConversationLineage(app.id, "conv-fork");
|
|
349
|
+
|
|
350
|
+
const loaded = getApp(app.id)!;
|
|
351
|
+
expect(loaded.conversationIds).toEqual(["conv-visible", "conv-fork"]);
|
|
352
|
+
expect(loaded.inheritedConversationIds).toBeUndefined();
|
|
353
|
+
expect(isDirectAppConversation(loaded, "conv-visible")).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createConversation,
|
|
6
|
+
getDisplayMetaForConversations,
|
|
7
|
+
} from "../persistence/conversation-crud.js";
|
|
8
|
+
import { ensureGroupMigration } from "../persistence/conversation-group-migration.js";
|
|
9
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
10
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
11
|
+
import { executeConversationGroupCreate } from "../tools/conversation-groups/group_create.js";
|
|
12
|
+
import { executeConversationGroupList } from "../tools/conversation-groups/group_list.js";
|
|
13
|
+
import { executeConversationMoveToGroup } from "../tools/conversation-groups/move_to_group.js";
|
|
14
|
+
import type { ToolContext } from "../tools/types.js";
|
|
15
|
+
|
|
16
|
+
await initializeDb();
|
|
17
|
+
ensureGroupMigration();
|
|
18
|
+
|
|
19
|
+
function getRawDb(): Database {
|
|
20
|
+
return (getDb() as unknown as { $client: Database }).$client;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ctx: ToolContext = {
|
|
24
|
+
workingDir: "/tmp",
|
|
25
|
+
conversationId: "test-conversation",
|
|
26
|
+
trustClass: "guardian",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function clearState(): void {
|
|
30
|
+
const raw = getRawDb();
|
|
31
|
+
raw.run("DELETE FROM conversations");
|
|
32
|
+
raw.run("DELETE FROM conversation_groups WHERE is_system_group = 0");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractGroupId(content: string): string {
|
|
36
|
+
const match = content.match(/id: ([0-9a-f-]{36})/);
|
|
37
|
+
expect(match).not.toBeNull();
|
|
38
|
+
return match![1];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function groupIdOf(conversationId: string): string | null {
|
|
42
|
+
return (
|
|
43
|
+
getDisplayMetaForConversations([conversationId]).get(conversationId)
|
|
44
|
+
?.groupId ?? null
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── conversation_group_list ─────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe("conversation_group_list tool", () => {
|
|
51
|
+
beforeEach(clearState);
|
|
52
|
+
|
|
53
|
+
test("lists system groups", async () => {
|
|
54
|
+
const result = await executeConversationGroupList({}, ctx);
|
|
55
|
+
|
|
56
|
+
expect(result.isError).toBe(false);
|
|
57
|
+
expect(result.content).toContain("Pinned (id: system:pinned)");
|
|
58
|
+
expect(result.content).toContain("Recents (id: system:all)");
|
|
59
|
+
expect(result.content).toContain("[system group]");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("lists custom groups after creation", async () => {
|
|
63
|
+
await executeConversationGroupCreate({ name: "Work" }, ctx);
|
|
64
|
+
|
|
65
|
+
const result = await executeConversationGroupList({}, ctx);
|
|
66
|
+
expect(result.isError).toBe(false);
|
|
67
|
+
expect(result.content).toContain("Work");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── conversation_group_create ───────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe("conversation_group_create tool", () => {
|
|
74
|
+
beforeEach(clearState);
|
|
75
|
+
|
|
76
|
+
test("creates a custom group", async () => {
|
|
77
|
+
const result = await executeConversationGroupCreate(
|
|
78
|
+
{ name: "Travel planning" },
|
|
79
|
+
ctx,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(result.isError).toBe(false);
|
|
83
|
+
expect(result.content).toContain('Created group "Travel planning"');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("trims the name", async () => {
|
|
87
|
+
const result = await executeConversationGroupCreate(
|
|
88
|
+
{ name: " Padded " },
|
|
89
|
+
ctx,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(result.isError).toBe(false);
|
|
93
|
+
expect(result.content).toContain('Created group "Padded"');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("reuses an existing group with the same name (case-insensitive)", async () => {
|
|
97
|
+
const first = await executeConversationGroupCreate({ name: "Work" }, ctx);
|
|
98
|
+
const firstId = extractGroupId(first.content);
|
|
99
|
+
|
|
100
|
+
const second = await executeConversationGroupCreate({ name: "work" }, ctx);
|
|
101
|
+
expect(second.isError).toBe(false);
|
|
102
|
+
expect(second.content).toContain("already exists");
|
|
103
|
+
expect(second.content).toContain(firstId);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("rejects system group names", async () => {
|
|
107
|
+
const result = await executeConversationGroupCreate(
|
|
108
|
+
{ name: "pinned" },
|
|
109
|
+
ctx,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(result.isError).toBe(true);
|
|
113
|
+
expect(result.content).toContain("system group");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("rejects missing name", async () => {
|
|
117
|
+
const result = await executeConversationGroupCreate({}, ctx);
|
|
118
|
+
|
|
119
|
+
expect(result.isError).toBe(true);
|
|
120
|
+
expect(result.content).toContain("name is required");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── conversation_move_to_group ──────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe("conversation_move_to_group tool", () => {
|
|
127
|
+
beforeEach(clearState);
|
|
128
|
+
|
|
129
|
+
test("moves a conversation into a custom group by name", async () => {
|
|
130
|
+
createConversation({ id: "conv-1", title: "Trip ideas" });
|
|
131
|
+
await executeConversationGroupCreate({ name: "Travel" }, ctx);
|
|
132
|
+
|
|
133
|
+
const result = await executeConversationMoveToGroup(
|
|
134
|
+
{ group: "travel", conversation_id: "conv-1" },
|
|
135
|
+
ctx,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(result.isError).toBe(false);
|
|
139
|
+
expect(result.content).toContain('Moved "Trip ideas" to group "Travel"');
|
|
140
|
+
expect(groupIdOf("conv-1")).not.toBe("system:all");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("moves a conversation by group id", async () => {
|
|
144
|
+
createConversation({ id: "conv-2" });
|
|
145
|
+
const created = await executeConversationGroupCreate({ name: "Work" }, ctx);
|
|
146
|
+
const groupId = extractGroupId(created.content);
|
|
147
|
+
|
|
148
|
+
const result = await executeConversationMoveToGroup(
|
|
149
|
+
{ group: groupId, conversation_id: "conv-2" },
|
|
150
|
+
ctx,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(result.isError).toBe(false);
|
|
154
|
+
expect(groupIdOf("conv-2")).toBe(groupId);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("defaults to the current conversation", async () => {
|
|
158
|
+
createConversation({ id: "test-conversation", title: "This chat" });
|
|
159
|
+
await executeConversationGroupCreate({ name: "Inbox" }, ctx);
|
|
160
|
+
|
|
161
|
+
const result = await executeConversationMoveToGroup(
|
|
162
|
+
{ group: "Inbox" },
|
|
163
|
+
ctx,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(result.isError).toBe(false);
|
|
167
|
+
expect(result.content).toContain('Moved "This chat" to group "Inbox"');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("moving to Pinned pins the conversation", async () => {
|
|
171
|
+
createConversation({ id: "conv-pin" });
|
|
172
|
+
|
|
173
|
+
const result = await executeConversationMoveToGroup(
|
|
174
|
+
{ group: "Pinned", conversation_id: "conv-pin" },
|
|
175
|
+
ctx,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(result.isError).toBe(false);
|
|
179
|
+
expect(result.content).toContain("pinned");
|
|
180
|
+
expect(groupIdOf("conv-pin")).toBe("system:pinned");
|
|
181
|
+
const pinned = getRawDb()
|
|
182
|
+
.query("SELECT is_pinned FROM conversations WHERE id = ?")
|
|
183
|
+
.get("conv-pin") as { is_pinned: number };
|
|
184
|
+
expect(pinned.is_pinned).toBe(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("preserves display_order across a move", async () => {
|
|
188
|
+
createConversation({ id: "conv-ord" });
|
|
189
|
+
getRawDb().run(
|
|
190
|
+
"UPDATE conversations SET display_order = 7 WHERE id = 'conv-ord'",
|
|
191
|
+
);
|
|
192
|
+
await executeConversationGroupCreate({ name: "Ordered" }, ctx);
|
|
193
|
+
|
|
194
|
+
const result = await executeConversationMoveToGroup(
|
|
195
|
+
{ group: "Ordered", conversation_id: "conv-ord" },
|
|
196
|
+
ctx,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
expect(result.isError).toBe(false);
|
|
200
|
+
const row = getRawDb()
|
|
201
|
+
.query("SELECT display_order FROM conversations WHERE id = ?")
|
|
202
|
+
.get("conv-ord") as { display_order: number | null };
|
|
203
|
+
expect(row.display_order).toBe(7);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("no-op when already in the target group", async () => {
|
|
207
|
+
createConversation({ id: "conv-3", title: "Settled" });
|
|
208
|
+
|
|
209
|
+
const result = await executeConversationMoveToGroup(
|
|
210
|
+
{ group: "Recents", conversation_id: "conv-3" },
|
|
211
|
+
ctx,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(result.isError).toBe(false);
|
|
215
|
+
expect(result.content).toContain("already in group");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("errors on unknown group with available list", async () => {
|
|
219
|
+
createConversation({ id: "conv-4" });
|
|
220
|
+
|
|
221
|
+
const result = await executeConversationMoveToGroup(
|
|
222
|
+
{ group: "Nonexistent", conversation_id: "conv-4" },
|
|
223
|
+
ctx,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(result.isError).toBe(true);
|
|
227
|
+
expect(result.content).toContain('No group named "Nonexistent"');
|
|
228
|
+
expect(result.content).toContain("Available groups:");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("errors on ambiguous group name", async () => {
|
|
232
|
+
// Two custom groups whose names differ only by case
|
|
233
|
+
await executeConversationGroupCreate({ name: "Alpha" }, ctx);
|
|
234
|
+
// Bypass the tool's dedup to create the case-variant directly
|
|
235
|
+
getRawDb().run(
|
|
236
|
+
"INSERT INTO conversation_groups (id, name, sort_position, is_system_group, created_at, updated_at) VALUES ('dup-group-id', 'alpha', 99, 0, 0, 0)",
|
|
237
|
+
);
|
|
238
|
+
createConversation({ id: "conv-5" });
|
|
239
|
+
|
|
240
|
+
const result = await executeConversationMoveToGroup(
|
|
241
|
+
{ group: "ALPHA", conversation_id: "conv-5" },
|
|
242
|
+
ctx,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(result.isError).toBe(true);
|
|
246
|
+
expect(result.content).toContain("ambiguous");
|
|
247
|
+
expect(result.content).toContain("Retry with the group id");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("errors on unknown conversation", async () => {
|
|
251
|
+
const result = await executeConversationMoveToGroup(
|
|
252
|
+
{ group: "Recents", conversation_id: "missing-conv" },
|
|
253
|
+
ctx,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(result.isError).toBe(true);
|
|
257
|
+
expect(result.content).toContain("no conversation found");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("notes hidden conversation types", async () => {
|
|
261
|
+
createConversation({ id: "conv-bg", conversationType: "background" });
|
|
262
|
+
await executeConversationGroupCreate({ name: "Visible" }, ctx);
|
|
263
|
+
|
|
264
|
+
const result = await executeConversationMoveToGroup(
|
|
265
|
+
{ group: "Visible", conversation_id: "conv-bg" },
|
|
266
|
+
ctx,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
expect(result.isError).toBe(false);
|
|
270
|
+
expect(result.content).toContain("hidden from the sidebar");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("rejects missing group", async () => {
|
|
274
|
+
const result = await executeConversationMoveToGroup({}, ctx);
|
|
275
|
+
|
|
276
|
+
expect(result.isError).toBe(true);
|
|
277
|
+
expect(result.content).toContain("group is required");
|
|
278
|
+
});
|
|
279
|
+
});
|