@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.
Files changed (49) hide show
  1. package/openapi.yaml +18 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/app-conversation-ids-backfill.test.ts +33 -2
  4. package/src/__tests__/app-conversation-ids.test.ts +204 -0
  5. package/src/__tests__/conversation-group-tools.test.ts +279 -0
  6. package/src/__tests__/conversation-lineage.test.ts +235 -0
  7. package/src/__tests__/platform-callback-registration.test.ts +10 -10
  8. package/src/__tests__/resolve-app-id.test.ts +32 -0
  9. package/src/__tests__/starter-task-flow.test.ts +24 -4
  10. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  11. package/src/__tests__/tool-side-effects-slack-dm.test.ts +1 -0
  12. package/src/__tests__/workspace-policy.test.ts +83 -2
  13. package/src/api/responses/subagent-detail.ts +13 -0
  14. package/src/apps/app-store.ts +95 -4
  15. package/src/calls/__tests__/voice-session-bridge.test.ts +71 -3
  16. package/src/calls/voice-session-bridge.ts +22 -7
  17. package/src/cli/commands/webhooks.help.ts +9 -4
  18. package/src/config/bundled-skills/conversation-groups/SKILL.md +29 -0
  19. package/src/config/bundled-skills/conversation-groups/TOOLS.json +58 -0
  20. package/src/config/bundled-skills/conversation-groups/tools/group-create.ts +12 -0
  21. package/src/config/bundled-skills/conversation-groups/tools/group-list.ts +12 -0
  22. package/src/config/bundled-skills/conversation-groups/tools/move-to-group.ts +12 -0
  23. package/src/config/bundled-skills/schedule/SKILL.md +2 -0
  24. package/src/config/feature-flag-registry.json +0 -8
  25. package/src/daemon/conversation-lineage.ts +76 -0
  26. package/src/daemon/conversation-surfaces.ts +4 -10
  27. package/src/daemon/tool-side-effects.ts +27 -11
  28. package/src/live-voice/__tests__/live-voice-continuation-fork-hydration.test.ts +286 -0
  29. package/src/live-voice/__tests__/live-voice-vad.test.ts +889 -167
  30. package/src/live-voice/live-voice-session.ts +733 -201
  31. package/src/permissions/checker.test.ts +213 -6
  32. package/src/permissions/checker.ts +85 -14
  33. package/src/permissions/workspace-policy.ts +51 -18
  34. package/src/persistence/conversation-parent.ts +27 -0
  35. package/src/persistence/migrations/354-backfill-app-conversation-lineage.ts +41 -0
  36. package/src/persistence/steps.ts +2 -0
  37. package/src/prompts/sections.ts +4 -16
  38. package/src/runtime/routes/__tests__/subagents-routes.test.ts +250 -0
  39. package/src/runtime/routes/__tests__/webhook-routes.test.ts +201 -0
  40. package/src/runtime/routes/subagents-routes.ts +35 -8
  41. package/src/runtime/routes/webhook-routes.ts +89 -30
  42. package/src/tools/__tests__/conversation-group-tool-input-schemas.test.ts +61 -0
  43. package/src/tools/apps/resolve-app-id.ts +15 -3
  44. package/src/tools/conversation-groups/group_create.ts +63 -0
  45. package/src/tools/conversation-groups/group_list.ts +20 -0
  46. package/src/tools/conversation-groups/group_shared.ts +52 -0
  47. package/src/tools/conversation-groups/move_to_group.ts +106 -0
  48. package/src/util/platform.ts +15 -0
  49. 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: Parent conversation ID (required)
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
- Subagents not in the response are orphaned.
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. On self-hosted assistants, uses the configured ingress.publicBaseUrl.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.0-staging.1",
3
+ "version": "0.11.0-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`,
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
+ });