@vellumai/assistant 0.11.2-dev.202608052129.f237b1e → 0.11.2-dev.202608052227.261a826

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 CHANGED
@@ -6585,6 +6585,16 @@ paths:
6585
6585
  description:
6586
6586
  Filter by origin channel. When provided, only conversations with this exact origin_channel value are
6587
6587
  returned. Omit to include all channels.
6588
+ - name: groupId
6589
+ in: query
6590
+ required: false
6591
+ schema:
6592
+ type: string
6593
+ description:
6594
+ Filter to a single group, so each sidebar section can load independently of the paginated list. Pass
6595
+ "system:all" for conversations in no group, "system:pinned" for the Pinned section, or a custom group id. A
6596
+ group-scoped request is ordered by the user's arrangement (display order, then recency) and never has pinned
6597
+ rows appended to it. Omit to span every group.
6588
6598
  responses:
6589
6599
  "200":
6590
6600
  description: Successful response
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608052129.f237b1e",
3
+ "version": "0.11.2-dev.202608052227.261a826",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -257,7 +257,7 @@ describe("conversation_move_to_group tool", () => {
257
257
  expect(result.content).toContain("no conversation found");
258
258
  });
259
259
 
260
- test("notes hidden conversation types", async () => {
260
+ test("tells the model a custom group surfaces a hidden conversation", async () => {
261
261
  createConversation({ id: "conv-bg", conversationType: "background" });
262
262
  await executeConversationGroupCreate({ name: "Visible" }, ctx);
263
263
 
@@ -267,7 +267,37 @@ describe("conversation_move_to_group tool", () => {
267
267
  );
268
268
 
269
269
  expect(result.isError).toBe(false);
270
- expect(result.content).toContain("hidden from the sidebar");
270
+ expect(result.content).toContain("surfaces it into the sidebar");
271
+ });
272
+
273
+ test("does not claim a move to Recents surfaces a hidden conversation", async () => {
274
+ // Recents is a removal target, not a promotion: the write path leaves an
275
+ // unpromoted background row hidden there, so saying otherwise would report
276
+ // an outcome to the model that did not happen.
277
+ createConversation({ id: "conv-bg-all", conversationType: "background" });
278
+
279
+ const result = await executeConversationMoveToGroup(
280
+ { group: "system:all", conversation_id: "conv-bg-all" },
281
+ ctx,
282
+ );
283
+
284
+ expect(result.isError).toBe(false);
285
+ expect(result.content).not.toContain("surfaces it into the sidebar");
286
+ });
287
+
288
+ test("does not claim a move to Background surfaces a hidden conversation", async () => {
289
+ createConversation({
290
+ id: "conv-bg-demote",
291
+ conversationType: "background",
292
+ });
293
+
294
+ const result = await executeConversationMoveToGroup(
295
+ { group: "system:background", conversation_id: "conv-bg-demote" },
296
+ ctx,
297
+ );
298
+
299
+ expect(result.isError).toBe(false);
300
+ expect(result.content).not.toContain("surfaces it into the sidebar");
271
301
  });
272
302
 
273
303
  test("rejects missing group", async () => {
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Filing a background or scheduled conversation into a section the user reads
3
+ * promotes it durably.
4
+ *
5
+ * Placement into Pinned or a custom group stamps `surfaced_at`, so the
6
+ * promotion outlives the placement that caused it: taking the conversation
7
+ * back out returns it to Recents rather than hiding it. Demotion is the
8
+ * mirror image, and filing into `system:background` / `system:scheduled`
9
+ * clears the stamp.
10
+ *
11
+ * The cases below pin both edges. Under-promoting hides a conversation the
12
+ * user deliberately filed somewhere; over-promoting drags hidden automation
13
+ * into the sidebar, so the "what promotion must not do" block is as
14
+ * load-bearing as the rest.
15
+ */
16
+
17
+ import { beforeEach, describe, expect, test } from "bun:test";
18
+
19
+ import {
20
+ batchSetDisplayOrders,
21
+ createConversation,
22
+ } from "../persistence/conversation-crud.js";
23
+ import { listConversations } from "../persistence/conversation-queries.js";
24
+ import { getDb } from "../persistence/db-connection.js";
25
+ import { initializeDb } from "../persistence/db-init.js";
26
+ import { createGroup } from "../persistence/group-crud.js";
27
+ import { rawRun } from "../persistence/raw-query.js";
28
+ import { conversations } from "../persistence/schema/index.js";
29
+
30
+ await initializeDb();
31
+
32
+ function seedRoutedBackground(title: string, type: "background" | "scheduled") {
33
+ const conv = createConversation({ title, conversationType: type });
34
+ rawRun(
35
+ "test:routeToSystemBucket",
36
+ "UPDATE conversations SET group_id = ? WHERE id = ?",
37
+ type === "scheduled" ? "system:scheduled" : "system:background",
38
+ conv.id,
39
+ );
40
+ return conv;
41
+ }
42
+
43
+ /** Titles the sidebar's standard listing would render. */
44
+ function visibleTitles(): string[] {
45
+ return listConversations({ limit: 100 }).map((c) => c.title ?? "");
46
+ }
47
+
48
+ function titlesInGroup(groupId: string): string[] {
49
+ return listConversations({ limit: 100, groupId }).map((c) => c.title ?? "");
50
+ }
51
+
52
+ beforeEach(() => {
53
+ getDb().delete(conversations).run();
54
+ });
55
+
56
+ describe("pinning a background conversation", () => {
57
+ test("shows it in Pinned", () => {
58
+ const conv = seedRoutedBackground("bg-job", "background");
59
+ expect(visibleTitles()).toEqual([]);
60
+
61
+ batchSetDisplayOrders([
62
+ { id: conv.id, displayOrder: 0, groupId: "system:pinned" },
63
+ ]);
64
+
65
+ expect(titlesInGroup("system:pinned")).toEqual(["bg-job"]);
66
+ });
67
+
68
+ test("unpinning lands it in Recents rather than hiding it again", () => {
69
+ const conv = seedRoutedBackground("bg-job", "background");
70
+ batchSetDisplayOrders([
71
+ { id: conv.id, displayOrder: 0, groupId: "system:pinned" },
72
+ ]);
73
+
74
+ batchSetDisplayOrders([
75
+ { id: conv.id, displayOrder: null, groupId: "system:all" },
76
+ ]);
77
+
78
+ expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
79
+ });
80
+ });
81
+
82
+ describe("filing a background conversation into a custom group", () => {
83
+ test("shows it in that group", () => {
84
+ const group = createGroup("Car Chat");
85
+ const conv = seedRoutedBackground("bg-job", "background");
86
+
87
+ batchSetDisplayOrders([
88
+ { id: conv.id, displayOrder: 0, groupId: group.id },
89
+ ]);
90
+
91
+ expect(titlesInGroup(group.id)).toEqual(["bg-job"]);
92
+ });
93
+
94
+ test("removing it from the group lands it in Recents rather than losing it", () => {
95
+ const group = createGroup("Car Chat");
96
+ const conv = seedRoutedBackground("bg-job", "background");
97
+ batchSetDisplayOrders([
98
+ { id: conv.id, displayOrder: 0, groupId: group.id },
99
+ ]);
100
+
101
+ batchSetDisplayOrders([
102
+ { id: conv.id, displayOrder: null, groupId: "system:all" },
103
+ ]);
104
+
105
+ expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
106
+ expect(visibleTitles()).toEqual(["bg-job"]);
107
+ });
108
+
109
+ test("applies to scheduled conversations too", () => {
110
+ const group = createGroup("Morning Briefs");
111
+ const conv = seedRoutedBackground("sched-job", "scheduled");
112
+
113
+ batchSetDisplayOrders([
114
+ { id: conv.id, displayOrder: 0, groupId: group.id },
115
+ ]);
116
+ batchSetDisplayOrders([
117
+ { id: conv.id, displayOrder: null, groupId: "system:all" },
118
+ ]);
119
+
120
+ expect(titlesInGroup("system:all")).toEqual(["sched-job"]);
121
+ });
122
+ });
123
+
124
+ describe("what promotion must not do", () => {
125
+ test("filing into system:background still demotes", () => {
126
+ const conv = seedRoutedBackground("bg-job", "background");
127
+ // Promote first, so the demotion has something to undo.
128
+ batchSetDisplayOrders([
129
+ { id: conv.id, displayOrder: 0, groupId: "system:pinned" },
130
+ ]);
131
+ expect(titlesInGroup("system:pinned")).toEqual(["bg-job"]);
132
+
133
+ batchSetDisplayOrders([
134
+ { id: conv.id, displayOrder: null, groupId: "system:background" },
135
+ ]);
136
+
137
+ expect(visibleTitles()).toEqual([]);
138
+ });
139
+
140
+ test("a background conversation nobody placed anywhere stays hidden", () => {
141
+ seedRoutedBackground("untouched", "background");
142
+
143
+ expect(visibleTitles()).toEqual([]);
144
+ });
145
+
146
+ test("moving to system:all does not by itself promote", () => {
147
+ // Removal is not a promotion: a row that was never filed into a section
148
+ // the user reads must not become visible just by being moved out of its
149
+ // system bucket.
150
+ const conv = seedRoutedBackground("bg-job", "background");
151
+
152
+ batchSetDisplayOrders([
153
+ { id: conv.id, displayOrder: null, groupId: "system:all" },
154
+ ]);
155
+
156
+ expect(visibleTitles()).toEqual([]);
157
+ });
158
+
159
+ test("unpinning through the legacy isPinned path still lands in Recents", () => {
160
+ // Old clients send `isPinned: false` with no `groupId`, and that branch
161
+ // restores the row's original system bucket to keep its provenance. It
162
+ // deliberately does not clear the promotion: unpinning is not a demotion,
163
+ // so the conversation belongs in Recents afterwards, the same as the
164
+ // modern path that sends `groupId: "system:all"`.
165
+ //
166
+ // The row therefore sits in `system:background` while remaining visible.
167
+ // That is the intended split: the bucket records where the conversation
168
+ // came from, `surfaced_at` records that the user promoted it, and only
169
+ // an explicit demotion clears the latter.
170
+ const conv = seedRoutedBackground("bg-job", "background");
171
+ batchSetDisplayOrders([
172
+ { id: conv.id, displayOrder: 0, groupId: "system:pinned" },
173
+ ]);
174
+
175
+ batchSetDisplayOrders([
176
+ { id: conv.id, displayOrder: null, isPinned: false },
177
+ ]);
178
+
179
+ expect(visibleTitles()).toEqual(["bg-job"]);
180
+ expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
181
+ });
182
+
183
+ test("standard conversations are not stamped as surfaced", () => {
184
+ // They are already visible, so stamping them would leave `surfaced_at`
185
+ // meaning nothing.
186
+ const group = createGroup("Car Chat");
187
+ const conv = createConversation("plain-chat");
188
+
189
+ batchSetDisplayOrders([
190
+ { id: conv.id, displayOrder: 0, groupId: group.id },
191
+ ]);
192
+
193
+ const row = getDb()
194
+ .all(`SELECT surfaced_at FROM conversations WHERE id = '${conv.id}'`)
195
+ .at(0) as { surfaced_at: number | null };
196
+ expect(row.surfaced_at).toBeNull();
197
+ });
198
+ });
@@ -33,7 +33,7 @@ describe("custom-group standard-listing visibility", () => {
33
33
  });
34
34
 
35
35
  function listedIds(): string[] {
36
- return listConversations(undefined, "standard").map((c) => c.id);
36
+ return listConversations({ conversationType: "standard" }).map((c) => c.id);
37
37
  }
38
38
 
39
39
  test("scheduled conversation in a custom group is listed", () => {
package/src/cli.ts CHANGED
@@ -513,7 +513,7 @@ export async function startCli(): Promise<void> {
513
513
  if (content === "/conversations") {
514
514
  pendingSessionPick = true;
515
515
  try {
516
- const rows = listConversations(20);
516
+ const rows = listConversations({ limit: 20 });
517
517
  const conversations = rows.map((r) => ({
518
518
  id: r.id,
519
519
  title: r.title || "Untitled",
@@ -22,7 +22,7 @@ export async function performConversationSearch(
22
22
  ) {
23
23
  // Treat "*" as a list-all wildcard — FTS treats it as a literal character.
24
24
  if (params.query.trim() === "*") {
25
- const rows = listConversations(params.limit);
25
+ const rows = listConversations({ limit: params.limit });
26
26
  return rows.map((r) => ({
27
27
  conversationId: r.id,
28
28
  conversationTitle: r.title,
@@ -75,6 +75,8 @@ import {
75
75
  BACKGROUND_CONVERSATION_TYPES,
76
76
  type ConversationCreateType,
77
77
  isHiddenMessageMetadata,
78
+ PINNED_GROUP_ID,
79
+ UNGROUPED_GROUP_ID,
78
80
  } from "./conversation-types.js";
79
81
  import { runAsyncSqlite } from "./db-async-query.js";
80
82
  import {
@@ -1040,7 +1042,7 @@ export function createConversation(
1040
1042
  // write-contended paths wrap the call in `withSqliteRetry`, and because the
1041
1043
  // insert+update are atomic here, such a retry re-runs the whole thing cleanly
1042
1044
  // (a failed attempt rolls back, so no half-written row is ever left behind).
1043
- const effectiveGroupId = groupId ?? "system:all";
1045
+ const effectiveGroupId = groupId ?? UNGROUPED_GROUP_ID;
1044
1046
  const raw = getSqliteFrom(db);
1045
1047
  raw.exec("SAVEPOINT create_conv");
1046
1048
  try {
@@ -1049,7 +1051,7 @@ export function createConversation(
1049
1051
  "conversation:create:setGroup",
1050
1052
  "UPDATE conversations SET group_id = ?, is_pinned = ? WHERE id = ?",
1051
1053
  effectiveGroupId,
1052
- effectiveGroupId === "system:pinned" ? 1 : 0,
1054
+ effectiveGroupId === PINNED_GROUP_ID ? 1 : 0,
1053
1055
  id,
1054
1056
  );
1055
1057
  raw.exec("RELEASE create_conv");
@@ -4031,7 +4033,7 @@ export function batchSetDisplayOrders(
4031
4033
  // the entire batch.
4032
4034
  let safeGroupId = update.groupId;
4033
4035
  if (safeGroupId === null) {
4034
- safeGroupId = "system:all";
4036
+ safeGroupId = UNGROUPED_GROUP_ID;
4035
4037
  } else if (
4036
4038
  !rawGet<{ id: string }>(
4037
4039
  "conversation:batchSetDisplayOrders:groupCheck",
@@ -4039,7 +4041,7 @@ export function batchSetDisplayOrders(
4039
4041
  safeGroupId,
4040
4042
  )
4041
4043
  ) {
4042
- safeGroupId = "system:all";
4044
+ safeGroupId = UNGROUPED_GROUP_ID;
4043
4045
  }
4044
4046
  // Moving a conversation into the Scheduled/Background system groups
4045
4047
  // is an explicit demotion out of Recents, so clear any `surfaced_at`
@@ -4049,15 +4051,49 @@ export function batchSetDisplayOrders(
4049
4051
  const clearsSurfaced =
4050
4052
  safeGroupId === "system:background" ||
4051
4053
  safeGroupId === "system:scheduled";
4052
- rawRun(
4053
- "conversation:batchSetDisplayOrders:group",
4054
- `UPDATE conversations SET display_order = ?, is_pinned = ?, group_id = ?${
4055
- clearsSurfaced ? ", surfaced_at = NULL" : ""
4056
- } WHERE id = ?`,
4054
+ // Filing a conversation into a section the user reads (Pinned or one
4055
+ // of their own groups) is the mirror image: an explicit promotion, so
4056
+ // it stamps `surfaced_at` the way the surface API does.
4057
+ //
4058
+ // The stamp is what makes the promotion durable. Visibility must not
4059
+ // depend on where the row currently sits, or removing it from a group
4060
+ // would hide it instead of returning it to Recents, and pinning would
4061
+ // not show it at all: `system:pinned` fails the custom-group arm of
4062
+ // `standardListingVisibilitySql` on that arm's `system:` prefix check.
4063
+ //
4064
+ // Only background and scheduled rows need it: everything else is
4065
+ // already visible, and stamping them would leave `surfaced_at`
4066
+ // meaning nothing. `COALESCE` keeps the original promotion time when
4067
+ // the row is re-filed later.
4068
+ const promotes =
4069
+ !clearsSurfaced &&
4070
+ safeGroupId !== UNGROUPED_GROUP_ID &&
4071
+ (safeGroupId === PINNED_GROUP_ID ||
4072
+ !safeGroupId.startsWith("system:"));
4073
+ const setClauses = [
4074
+ "display_order = ?",
4075
+ "is_pinned = ?",
4076
+ "group_id = ?",
4077
+ ];
4078
+ const params: Array<string | number | null> = [
4057
4079
  update.displayOrder,
4058
- safeGroupId === "system:pinned" ? 1 : 0,
4080
+ safeGroupId === PINNED_GROUP_ID ? 1 : 0,
4059
4081
  safeGroupId,
4060
- update.id,
4082
+ ];
4083
+ if (clearsSurfaced) {
4084
+ setClauses.push("surfaced_at = NULL");
4085
+ } else if (promotes) {
4086
+ setClauses.push(
4087
+ "surfaced_at = CASE WHEN conversation_type IN ('background', 'scheduled')" +
4088
+ " THEN COALESCE(surfaced_at, ?) ELSE surfaced_at END",
4089
+ );
4090
+ params.push(Date.now());
4091
+ }
4092
+ params.push(update.id);
4093
+ rawRun(
4094
+ "conversation:batchSetDisplayOrders:group",
4095
+ `UPDATE conversations SET ${setClauses.join(", ")} WHERE id = ?`,
4096
+ ...params,
4061
4097
  );
4062
4098
  } else if (update.isPinned === undefined) {
4063
4099
  // Only displayOrder provided — preserve existing pin state and group.
@@ -136,7 +136,13 @@ export async function deleteConversation(id: string): Promise<void> {
136
136
  }
137
137
  }
138
138
 
139
- /** List conversation rows, newest first. */
139
+ /**
140
+ * List conversation rows, newest first.
141
+ *
142
+ * The positional signature is part of the plugin API surface and stays
143
+ * stable; the underlying query takes a named filter, which this maps onto.
144
+ * Omitted arguments stay omitted so the query's own defaults apply.
145
+ */
140
146
  export async function listConversations(
141
147
  limit?: number,
142
148
  conversationType?: ConversationType,
@@ -145,8 +151,13 @@ export async function listConversations(
145
151
  originChannel?: string,
146
152
  ): Promise<ConversationRow[]> {
147
153
  const { listConversations: fn } = await import("./conversation-queries.js");
148
- // Explicit `undefined` arguments fall through to the underlying defaults.
149
- return fn(limit, conversationType, offset, archiveStatus, originChannel);
154
+ return fn({
155
+ ...(limit !== undefined ? { limit } : {}),
156
+ ...(conversationType !== undefined ? { conversationType } : {}),
157
+ ...(offset !== undefined ? { offset } : {}),
158
+ ...(archiveStatus !== undefined ? { archiveStatus } : {}),
159
+ ...(originChannel !== undefined ? { originChannel } : {}),
160
+ });
150
161
  }
151
162
 
152
163
  /** Sparse lexical search over stored message text; ranked message-id hits. */
@@ -14,7 +14,11 @@ import { parseConversation } from "./conversation-crud.js";
14
14
  import { ensureDisplayOrderMigration } from "./conversation-display-order-migration.js";
15
15
  import { ensureGroupMigration } from "./conversation-group-migration.js";
16
16
  import { searchMessageIdsLexical } from "./conversation-search-lexical.js";
17
- import type { ConversationType } from "./conversation-types.js";
17
+ import {
18
+ type ConversationType,
19
+ PINNED_GROUP_ID,
20
+ UNGROUPED_GROUP_ID,
21
+ } from "./conversation-types.js";
18
22
  import { getDb } from "./db-connection.js";
19
23
  import { tokenize } from "./embeddings/sparse-tokenize.js";
20
24
  import {
@@ -236,34 +240,132 @@ function conversationTypeClause(type: ConversationType) {
236
240
  }
237
241
  }
238
242
 
243
+ /**
244
+ * The predicates that select which conversations a listing covers, shared by
245
+ * {@link listConversations} and {@link countConversations} so a list and its
246
+ * count can never disagree about what they are describing.
247
+ *
248
+ * Named rather than positional on purpose: `originChannel` and `groupId` are
249
+ * both optional strings, and as adjacent positional arguments they were a
250
+ * transposition waiting to happen.
251
+ */
252
+ export interface ConversationListFilter {
253
+ conversationType?: ConversationType;
254
+ archiveStatus?: ArchiveStatusFilter;
255
+ originChannel?: string;
256
+ /**
257
+ * Restrict to one group. {@link UNGROUPED_GROUP_ID} selects rows in no
258
+ * group (the sidebar's flat list), {@link PINNED_GROUP_ID} the Pinned
259
+ * section, and a UUID one custom group. Omit to span every group.
260
+ */
261
+ groupId?: string;
262
+ }
263
+
264
+ export interface ConversationListQuery extends ConversationListFilter {
265
+ limit?: number;
266
+ offset?: number;
267
+ }
268
+
269
+ /**
270
+ * SQL predicate for {@link ConversationListFilter.groupId}.
271
+ *
272
+ * The ungrouped case has to match NULL as well as the literal sentinel:
273
+ * `group_id` is only written when a conversation is filed somewhere, so the
274
+ * overwhelming majority of rows carry NULL rather than `'system:all'`.
275
+ *
276
+ * `group_id` is referenced as raw SQL because it is not a Drizzle column:
277
+ * it, `display_order`, and `is_pinned` are added by the lazy
278
+ * `ensureDisplayOrderMigration` / `ensureGroupMigration` steps rather than
279
+ * declared in `schema/conversations.ts`.
280
+ */
281
+ function groupIdClause(groupId: string) {
282
+ if (groupId === UNGROUPED_GROUP_ID) {
283
+ // "Ungrouped" means every row the sidebar's flat list shows: not pinned
284
+ // and not filed in a custom group. It deliberately admits the system
285
+ // buckets, because a surfaced background or scheduled conversation keeps
286
+ // its `system:background` / `system:scheduled` group id (surfacing writes
287
+ // only `surfaced_at`) while the standard listing renders it in Recents.
288
+ // Whether such a row is actually visible stays with
289
+ // `conversationTypeClause`, which admits the surfaced ones and excludes
290
+ // the rest; matching on group id alone here would drop them.
291
+ return sql`(group_id IS NULL OR (group_id LIKE 'system:%' AND group_id != ${PINNED_GROUP_ID}))`;
292
+ }
293
+ // `group_id` is single-valued and authoritative, so a row belongs to
294
+ // exactly one section by construction. `is_pinned` is a derived duplicate
295
+ // that reads do not consult: `ensureGroupMigration` backfills
296
+ // `group_id = 'system:pinned'` for every legacy `is_pinned` row before any
297
+ // query runs, and pinning writes both together thereafter.
298
+ return sql`group_id = ${groupId}`;
299
+ }
300
+
301
+ /**
302
+ * Order a group's rows the way the user arranged them: by explicit
303
+ * `display_order` first, then by recency. Only meaningful for a real group;
304
+ * the ungrouped bucket is pure recency, since a row that was dragged inside a
305
+ * group and later removed keeps a stale `display_order` that must not
306
+ * resurface as a sort key.
307
+ */
308
+ function isUserOrderedGroup(groupId: string | undefined): boolean {
309
+ if (groupId === undefined) {
310
+ return false;
311
+ }
312
+ // Pinned and custom groups are the only drag-reorderable sections, so they
313
+ // are the only ones whose `display_order` means anything. Every other
314
+ // system bucket sorts by recency: `batchSetDisplayOrders` writes
315
+ // `display_order` alongside `group_id` when a row moves into
316
+ // `system:background` / `system:scheduled`, and that value persists through
317
+ // later moves, so honouring it would order the same section differently
318
+ // depending on whether it was fetched by `conversationType` or `groupId`.
319
+ return groupId === PINNED_GROUP_ID || !groupId.startsWith("system:");
320
+ }
321
+
322
+ function conversationListWhere(filter: ConversationListFilter) {
323
+ const {
324
+ conversationType = "standard",
325
+ archiveStatus = "active",
326
+ originChannel,
327
+ groupId,
328
+ } = filter;
329
+ return and(
330
+ conversationTypeClause(conversationType),
331
+ archiveStatusClause(archiveStatus) ?? undefined,
332
+ originChannel ? eq(conversations.originChannel, originChannel) : undefined,
333
+ groupId ? groupIdClause(groupId) : undefined,
334
+ );
335
+ }
336
+
239
337
  export function listConversations(
240
- limit?: number,
241
- conversationType: ConversationType = "standard",
242
- offset = 0,
243
- archiveStatus: ArchiveStatusFilter = "active",
244
- originChannel?: string,
338
+ query: ConversationListQuery = {},
245
339
  ): ConversationRow[] {
246
340
  ensureDisplayOrderMigration();
247
341
  ensureGroupMigration();
248
342
  const db = getDb();
249
- const typeCond = conversationTypeClause(conversationType);
250
- const archiveCond = archiveStatusClause(archiveStatus);
251
- const channelCond = originChannel
252
- ? eq(conversations.originChannel, originChannel)
253
- : undefined;
254
- const where = and(typeCond, archiveCond ?? undefined, channelCond);
255
- const query = db
343
+ const { limit, offset = 0, ...filter } = query;
344
+ const recency = desc(
345
+ sql`COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt})`,
346
+ );
347
+ // `id` closes the ordering so the sort is a total order. Rows tied on every
348
+ // preceding key would otherwise have no defined relative order, which
349
+ // becomes duplicated and skipped rows once a cursor pages across a tie
350
+ // boundary.
351
+ //
352
+ // Deliberately untested here: SQLite returns a stable arrangement for
353
+ // identical queries over identical data, so no assertion at this layer can
354
+ // distinguish a total order from a lucky one. The guarantee becomes
355
+ // observable, and gets its test, with the keyset pagination work.
356
+ const tiebreak = desc(conversations.id);
357
+ const orderBy = isUserOrderedGroup(filter.groupId)
358
+ ? [sql`COALESCE(display_order, 999999) ASC`, recency, tiebreak]
359
+ : [recency, tiebreak];
360
+ return db
256
361
  .select()
257
362
  .from(conversations)
258
- .where(where)
259
- .orderBy(
260
- desc(
261
- sql`COALESCE(${conversations.lastMessageAt}, ${conversations.updatedAt})`,
262
- ),
263
- )
363
+ .where(conversationListWhere(filter))
364
+ .orderBy(...orderBy)
264
365
  .limit(limit ?? 100)
265
- .offset(offset);
266
- return query.all().map(parseConversation);
366
+ .offset(offset)
367
+ .all()
368
+ .map(parseConversation);
267
369
  }
268
370
 
269
371
  /**
@@ -447,23 +549,21 @@ export function listConversationsByTitlePrefix(
447
549
  }));
448
550
  }
449
551
 
552
+ /**
553
+ * How many conversations {@link listConversations} would return for the same
554
+ * filter, ignoring `limit` / `offset`. Both read through
555
+ * {@link conversationListWhere}, so a page and its total always describe the
556
+ * same set.
557
+ */
450
558
  export function countConversations(
451
- conversationType: ConversationType = "standard",
452
- archiveStatus: ArchiveStatusFilter = "active",
453
- originChannel?: string,
559
+ filter: ConversationListFilter = {},
454
560
  ): number {
455
561
  ensureGroupMigration();
456
562
  const db = getDb();
457
- const typeCond = conversationTypeClause(conversationType);
458
- const archiveCond = archiveStatusClause(archiveStatus);
459
- const channelCond = originChannel
460
- ? eq(conversations.originChannel, originChannel)
461
- : undefined;
462
- const where = and(typeCond, archiveCond ?? undefined, channelCond);
463
563
  const [{ total }] = db
464
564
  .select({ total: count() })
465
565
  .from(conversations)
466
- .where(where)
566
+ .where(conversationListWhere(filter))
467
567
  .all();
468
568
  return total;
469
569
  }
@@ -257,3 +257,13 @@ export function isReplyPushIneligibleUserMessage(
257
257
  (!options?.replyDeliveredInAppOnly && isReplyDeliveredOffApp(metadata))
258
258
  );
259
259
  }
260
+
261
+ /**
262
+ * The reserved `group_id` values the sidebar's system sections use.
263
+ *
264
+ * `group_id` is nullable, and a conversation that has never been filed
265
+ * carries NULL rather than {@link UNGROUPED_GROUP_ID}, so predicates over the
266
+ * column read it through `COALESCE(group_id, 'system:all')`.
267
+ */
268
+ export const UNGROUPED_GROUP_ID = "system:all";
269
+ export const PINNED_GROUP_ID = "system:pinned";
@@ -396,7 +396,7 @@ describe("countConversations", () => {
396
396
  const priv = createConversation("private-1");
397
397
  setConversationType(priv.id, "private");
398
398
 
399
- expect(countConversations("background")).toBe(2);
399
+ expect(countConversations({ conversationType: "background" })).toBe(2);
400
400
  });
401
401
 
402
402
  test("includes standard conversations with group_id system:background in background count", () => {
@@ -411,13 +411,13 @@ describe("countConversations", () => {
411
411
  createConversation("foreground-1");
412
412
 
413
413
  // WHEN counting background conversations
414
- const bgCount = countConversations("background");
414
+ const bgCount = countConversations({ conversationType: "background" });
415
415
 
416
416
  // THEN the heartbeat conversation is included
417
417
  expect(bgCount).toBe(1);
418
418
 
419
419
  // AND excluded from the foreground count
420
- expect(countConversations("standard")).toBe(1);
420
+ expect(countConversations({ conversationType: "standard" })).toBe(1);
421
421
  });
422
422
 
423
423
  test("excludes standard conversations with group_id system:background from foreground count", () => {
@@ -432,7 +432,7 @@ describe("countConversations", () => {
432
432
 
433
433
  // WHEN counting foreground conversations
434
434
  // THEN the heartbeat is excluded
435
- expect(countConversations("standard")).toBe(2);
435
+ expect(countConversations({ conversationType: "standard" })).toBe(2);
436
436
  });
437
437
 
438
438
  test('"scheduled" count returns only scheduled rows', () => {
@@ -443,7 +443,7 @@ describe("countConversations", () => {
443
443
 
444
444
  // WHEN counting scheduled conversations
445
445
  // THEN only the scheduled row is counted (background is excluded)
446
- expect(countConversations("scheduled")).toBe(1);
446
+ expect(countConversations({ conversationType: "scheduled" })).toBe(1);
447
447
  });
448
448
 
449
449
  describe("archiveStatus", () => {
@@ -473,7 +473,12 @@ describe("countConversations", () => {
473
473
  a2.id,
474
474
  );
475
475
 
476
- expect(countConversations("standard", "archived")).toBe(2);
476
+ expect(
477
+ countConversations({
478
+ conversationType: "standard",
479
+ archiveStatus: "archived",
480
+ }),
481
+ ).toBe(2);
477
482
  });
478
483
 
479
484
  test('archiveStatus "all" returns both', () => {
@@ -486,7 +491,12 @@ describe("countConversations", () => {
486
491
  archived.id,
487
492
  );
488
493
 
489
- expect(countConversations("standard", "all")).toBe(2);
494
+ expect(
495
+ countConversations({
496
+ conversationType: "standard",
497
+ archiveStatus: "all",
498
+ }),
499
+ ).toBe(2);
490
500
  });
491
501
  });
492
502
  });
@@ -511,7 +521,10 @@ describe("listConversations", () => {
511
521
  createConversation("foreground-1");
512
522
 
513
523
  // WHEN listing background conversations
514
- const bgList = listConversations(100, "background");
524
+ const bgList = listConversations({
525
+ limit: 100,
526
+ conversationType: "background",
527
+ });
515
528
 
516
529
  // THEN both background and heartbeat conversations are returned
517
530
  expect(bgList).toHaveLength(2);
@@ -532,7 +545,10 @@ describe("listConversations", () => {
532
545
  createConversation("foreground-1");
533
546
 
534
547
  // WHEN listing foreground conversations
535
- const fgList = listConversations(100, "standard");
548
+ const fgList = listConversations({
549
+ limit: 100,
550
+ conversationType: "standard",
551
+ });
536
552
 
537
553
  // THEN only the foreground conversation is returned
538
554
  expect(fgList).toHaveLength(1);
@@ -549,14 +565,20 @@ describe("listConversations", () => {
549
565
  );
550
566
 
551
567
  // WHEN listing background conversations
552
- const bgList = listConversations(100, "background");
568
+ const bgList = listConversations({
569
+ limit: 100,
570
+ conversationType: "background",
571
+ });
553
572
 
554
573
  // THEN it appears in the background list
555
574
  expect(bgList).toHaveLength(1);
556
575
  expect(bgList[0]!.title).toBe("schedule-routed");
557
576
 
558
577
  // AND not in the foreground list
559
- const fgList = listConversations(100, "standard");
578
+ const fgList = listConversations({
579
+ limit: 100,
580
+ conversationType: "standard",
581
+ });
560
582
  expect(fgList).toHaveLength(0);
561
583
  });
562
584
 
@@ -567,7 +589,10 @@ describe("listConversations", () => {
567
589
  createConversation("foreground-1");
568
590
 
569
591
  // WHEN listing scheduled conversations
570
- const scheduledList = listConversations(100, "scheduled");
592
+ const scheduledList = listConversations({
593
+ limit: 100,
594
+ conversationType: "scheduled",
595
+ });
571
596
 
572
597
  // THEN only the scheduled conversation is returned
573
598
  expect(scheduledList).toHaveLength(1);
@@ -592,7 +617,10 @@ describe("listConversations", () => {
592
617
  );
593
618
 
594
619
  // WHEN listing scheduled conversations
595
- const scheduledList = listConversations(100, "scheduled");
620
+ const scheduledList = listConversations({
621
+ limit: 100,
622
+ conversationType: "scheduled",
623
+ });
596
624
 
597
625
  // THEN only the system:scheduled row appears
598
626
  expect(scheduledList).toHaveLength(1);
@@ -608,7 +636,10 @@ describe("listConversations", () => {
608
636
  });
609
637
 
610
638
  // WHEN listing scheduled conversations
611
- const scheduledList = listConversations(100, "scheduled");
639
+ const scheduledList = listConversations({
640
+ limit: 100,
641
+ conversationType: "scheduled",
642
+ });
612
643
 
613
644
  // THEN the subagent run is excluded
614
645
  expect(scheduledList).toHaveLength(0);
@@ -627,7 +658,10 @@ describe("listConversations", () => {
627
658
  );
628
659
 
629
660
  // WHEN listing without an explicit archiveStatus
630
- const rows = listConversations(100, "standard");
661
+ const rows = listConversations({
662
+ limit: 100,
663
+ conversationType: "standard",
664
+ });
631
665
 
632
666
  // THEN only the live conversation appears
633
667
  expect(rows).toHaveLength(1);
@@ -646,7 +680,11 @@ describe("listConversations", () => {
646
680
  );
647
681
 
648
682
  // WHEN listing with archiveStatus "archived"
649
- const rows = listConversations(100, "standard", 0, "archived");
683
+ const rows = listConversations({
684
+ limit: 100,
685
+ conversationType: "standard",
686
+ archiveStatus: "archived",
687
+ });
650
688
 
651
689
  // THEN only the archived conversation appears
652
690
  expect(rows).toHaveLength(1);
@@ -665,7 +703,11 @@ describe("listConversations", () => {
665
703
  );
666
704
 
667
705
  // WHEN listing with archiveStatus "all"
668
- const rows = listConversations(100, "standard", 0, "all");
706
+ const rows = listConversations({
707
+ limit: 100,
708
+ conversationType: "standard",
709
+ archiveStatus: "all",
710
+ });
669
711
 
670
712
  // THEN both conversations appear
671
713
  expect(rows).toHaveLength(2);
@@ -690,7 +732,11 @@ describe("listConversations", () => {
690
732
  );
691
733
 
692
734
  // WHEN listing archived background conversations
693
- const rows = listConversations(100, "background", 0, "archived");
735
+ const rows = listConversations({
736
+ limit: 100,
737
+ conversationType: "background",
738
+ archiveStatus: "archived",
739
+ });
694
740
 
695
741
  // THEN only the archived background row appears
696
742
  expect(rows).toHaveLength(1);
@@ -246,6 +246,260 @@ describe("GET /v1/conversations — conversationType", () => {
246
246
  });
247
247
  });
248
248
 
249
+ describe("GET /v1/conversations with groupId", () => {
250
+ function seedInGroup(title: string, groupId: string): string {
251
+ const conv = createConversation(title);
252
+ rawRun(
253
+ "test:fileIntoGroup",
254
+ "UPDATE conversations SET group_id = ? WHERE id = ?",
255
+ groupId,
256
+ conv.id,
257
+ );
258
+ return conv.id;
259
+ }
260
+
261
+ function seedPinned(title: string, displayOrder?: number): string {
262
+ const conv = createConversation(title);
263
+ rawRun(
264
+ "test:pinConversation",
265
+ "UPDATE conversations SET is_pinned = 1, group_id = 'system:pinned', display_order = ? WHERE id = ?",
266
+ displayOrder ?? null,
267
+ conv.id,
268
+ );
269
+ return conv.id;
270
+ }
271
+
272
+ beforeEach(() => {
273
+ clearConversations();
274
+ });
275
+
276
+ test("system:all returns only conversations in no group", async () => {
277
+ createConversation("ungrouped-1");
278
+ const group = createGroup("Car Chat");
279
+ seedInGroup("in-custom-group", group.id);
280
+ seedPinned("pinned-1");
281
+
282
+ const result = (await invoke({ groupId: "system:all" })) as ListResponse;
283
+
284
+ expect(result.conversations.map((c) => c.title)).toEqual(["ungrouped-1"]);
285
+ });
286
+
287
+ test("system:all matches rows whose group_id is NULL, not just the literal sentinel", async () => {
288
+ // `group_id` is only written when a conversation is filed somewhere, so
289
+ // almost every ungrouped row carries NULL. An equality-only predicate
290
+ // would return nothing at all here.
291
+ const conv = createConversation("never-filed");
292
+ rawRun(
293
+ "test:clearGroup",
294
+ "UPDATE conversations SET group_id = NULL WHERE id = ?",
295
+ conv.id,
296
+ );
297
+
298
+ const result = (await invoke({ groupId: "system:all" })) as ListResponse;
299
+
300
+ expect(result.conversations.map((c) => c.title)).toEqual(["never-filed"]);
301
+ });
302
+
303
+ test("system:pinned returns the Pinned section", async () => {
304
+ createConversation("ungrouped-1");
305
+ seedPinned("pinned-1");
306
+ seedPinned("pinned-2");
307
+
308
+ const result = (await invoke({ groupId: "system:pinned" })) as ListResponse;
309
+
310
+ expect(result.conversations.map((c) => c.title).sort()).toEqual([
311
+ "pinned-1",
312
+ "pinned-2",
313
+ ]);
314
+ });
315
+
316
+ test("a custom group id returns only that group's members", async () => {
317
+ const carChat = createGroup("Car Chat");
318
+ const recipes = createGroup("Recipes");
319
+ seedInGroup("car-1", carChat.id);
320
+ seedInGroup("car-2", carChat.id);
321
+ seedInGroup("recipe-1", recipes.id);
322
+ createConversation("ungrouped-1");
323
+
324
+ const result = (await invoke({ groupId: carChat.id })) as ListResponse;
325
+
326
+ expect(result.conversations.map((c) => c.title).sort()).toEqual([
327
+ "car-1",
328
+ "car-2",
329
+ ]);
330
+ });
331
+
332
+ test("a group-scoped page is ordered by the user's arrangement", async () => {
333
+ // Pinned and custom groups are drag-reorderable, so display order wins
334
+ // over recency inside a group.
335
+ seedPinned("third", 2);
336
+ seedPinned("first", 0);
337
+ seedPinned("second", 1);
338
+
339
+ const result = (await invoke({ groupId: "system:pinned" })) as ListResponse;
340
+
341
+ expect(result.conversations.map((c) => c.title)).toEqual([
342
+ "first",
343
+ "second",
344
+ "third",
345
+ ]);
346
+ });
347
+
348
+ test("a group-scoped page has no pinned rows appended to it", async () => {
349
+ // Injection exists so a client reading Pinned out of the unfiltered list
350
+ // still sees it. A caller that asked for one group gets that group.
351
+ const group = createGroup("Car Chat");
352
+ seedInGroup("car-1", group.id);
353
+ seedPinned("pinned-1");
354
+
355
+ const result = (await invoke({ groupId: group.id })) as ListResponse;
356
+
357
+ expect(result.conversations.map((c) => c.title)).toEqual(["car-1"]);
358
+ });
359
+
360
+ test("system:all keeps surfaced background and scheduled rows", async () => {
361
+ // Surfacing writes only `surfaced_at`, so a promoted row keeps its
362
+ // `system:background` / `system:scheduled` group id while the standard
363
+ // listing renders it in Recents. A group filter that matched NULL and
364
+ // "system:all" alone would drop it from the section that is supposed to
365
+ // show it.
366
+ const background = createConversation({
367
+ title: "surfaced-background",
368
+ conversationType: "background",
369
+ });
370
+ const scheduled = createConversation({
371
+ title: "surfaced-scheduled",
372
+ conversationType: "scheduled",
373
+ });
374
+ // The routed group id is the part that matters: heartbeat, reminders and
375
+ // schedule-job runs are filed into these system buckets, and surfacing
376
+ // does not clear that. A row left with a NULL group id would pass a
377
+ // NULL-only predicate and prove nothing.
378
+ rawRun(
379
+ "test:routeToSystemBucket",
380
+ "UPDATE conversations SET group_id = 'system:background', surfaced_at = ? WHERE id = ?",
381
+ Date.now(),
382
+ background.id,
383
+ );
384
+ rawRun(
385
+ "test:routeToSystemBucket",
386
+ "UPDATE conversations SET group_id = 'system:scheduled', surfaced_at = ? WHERE id = ?",
387
+ Date.now(),
388
+ scheduled.id,
389
+ );
390
+ createConversation("plain-ungrouped");
391
+
392
+ const result = (await invoke({ groupId: "system:all" })) as ListResponse;
393
+
394
+ expect(result.conversations.map((c) => c.title).sort()).toEqual([
395
+ "plain-ungrouped",
396
+ "surfaced-background",
397
+ "surfaced-scheduled",
398
+ ]);
399
+ });
400
+
401
+ test("system:all still excludes background rows that were never surfaced", async () => {
402
+ const quiet = createConversation({
403
+ title: "quiet-background",
404
+ conversationType: "background",
405
+ });
406
+ rawRun(
407
+ "test:routeToSystemBucket",
408
+ "UPDATE conversations SET group_id = 'system:background' WHERE id = ?",
409
+ quiet.id,
410
+ );
411
+ createConversation("plain-ungrouped");
412
+
413
+ const result = (await invoke({ groupId: "system:all" })) as ListResponse;
414
+
415
+ expect(result.conversations.map((c) => c.title)).toEqual([
416
+ "plain-ungrouped",
417
+ ]);
418
+ });
419
+
420
+ test("system:all and system:pinned partition the sections, never overlapping", async () => {
421
+ createConversation("ungrouped-1");
422
+ seedPinned("pinned-1");
423
+ const group = createGroup("Car Chat");
424
+ seedInGroup("car-1", group.id);
425
+
426
+ const ungrouped = (await invoke({ groupId: "system:all" })) as ListResponse;
427
+ const pinned = (await invoke({ groupId: "system:pinned" })) as ListResponse;
428
+ const custom = (await invoke({ groupId: group.id })) as ListResponse;
429
+
430
+ expect(ungrouped.conversations.map((c) => c.title)).toEqual([
431
+ "ungrouped-1",
432
+ ]);
433
+ expect(pinned.conversations.map((c) => c.title)).toEqual(["pinned-1"]);
434
+ expect(custom.conversations.map((c) => c.title)).toEqual(["car-1"]);
435
+ });
436
+
437
+ test("system buckets other than Pinned sort by recency, not stale display order", async () => {
438
+ // `display_order` persists through moves, so honouring it for
439
+ // system:background would order that section differently depending on
440
+ // whether it was fetched by conversationType or by groupId.
441
+ const older = createConversation({
442
+ title: "older",
443
+ conversationType: "background",
444
+ });
445
+ const newer = createConversation({
446
+ title: "newer",
447
+ conversationType: "background",
448
+ });
449
+ rawRun(
450
+ "test:staleOrder",
451
+ "UPDATE conversations SET surfaced_at = ?, display_order = 0, last_message_at = 1000 WHERE id = ?",
452
+ Date.now(),
453
+ older.id,
454
+ );
455
+ rawRun(
456
+ "test:staleOrder",
457
+ "UPDATE conversations SET surfaced_at = ?, display_order = 99, last_message_at = 2000 WHERE id = ?",
458
+ Date.now(),
459
+ newer.id,
460
+ );
461
+
462
+ const result = (await invoke({ groupId: "system:all" })) as ListResponse;
463
+
464
+ expect(result.conversations.map((c) => c.title)).toEqual([
465
+ "newer",
466
+ "older",
467
+ ]);
468
+ });
469
+
470
+ test("hasMore reflects the group's own total, not the whole table", async () => {
471
+ const group = createGroup("Car Chat");
472
+ seedInGroup("car-1", group.id);
473
+ seedInGroup("car-2", group.id);
474
+ for (let i = 0; i < 5; i++) {
475
+ createConversation(`ungrouped-${i}`);
476
+ }
477
+
478
+ const result = (await invoke({
479
+ groupId: group.id,
480
+ limit: "2",
481
+ })) as ListResponse;
482
+
483
+ expect(result.conversations).toHaveLength(2);
484
+ expect(result.hasMore).toBe(false);
485
+ });
486
+
487
+ test("omitting groupId is unchanged: every group is spanned and pinned still injects", async () => {
488
+ const group = createGroup("Car Chat");
489
+ seedInGroup("car-1", group.id);
490
+ createConversation("ungrouped-1");
491
+ seedPinned("pinned-1");
492
+
493
+ const result = (await invoke()) as ListResponse;
494
+
495
+ expect(result.conversations.map((c) => c.title).sort()).toEqual([
496
+ "car-1",
497
+ "pinned-1",
498
+ "ungrouped-1",
499
+ ]);
500
+ });
501
+ });
502
+
249
503
  describe("GET /v1/conversations/unread-count", () => {
250
504
  const unreadCountHandler = findHandler(
251
505
  CONVERSATION_LIST_ROUTES,
@@ -44,12 +44,11 @@ function handleListCli({ body = {} }: RouteHandlerArgs) {
44
44
  // user sees archived rows alongside live ones in the picker.
45
45
  const includeArchived = (body.includeArchived as boolean) ?? false;
46
46
 
47
- const rows = listConversations(
47
+ const rows = listConversations({
48
48
  limit,
49
- "standard",
50
- 0,
51
- includeArchived ? "all" : "active",
52
- );
49
+ conversationType: "standard",
50
+ archiveStatus: includeArchived ? "all" : "active",
51
+ });
53
52
  return {
54
53
  conversations: rows.map((c) => ({
55
54
  id: c.id,
@@ -140,7 +139,7 @@ function handleExportCli({ body = {} }: RouteHandlerArgs) {
140
139
  let conversationId = body.conversationId as string | undefined;
141
140
 
142
141
  if (!conversationId) {
143
- const all = listConversations(1);
142
+ const all = listConversations({ limit: 1 });
144
143
  if (all.length === 0) {
145
144
  throw new NotFoundError("No conversations found");
146
145
  }
@@ -151,12 +150,11 @@ function handleExportCli({ body = {} }: RouteHandlerArgs) {
151
150
  // behavior of letting `vellum export <prefix>` resolve an archived row.
152
151
  let conversation = getConversation(conversationId);
153
152
  if (!conversation) {
154
- const all = listConversations(
155
- Number.MAX_SAFE_INTEGER,
156
- "standard",
157
- 0,
158
- "all",
159
- );
153
+ const all = listConversations({
154
+ limit: Number.MAX_SAFE_INTEGER,
155
+ conversationType: "standard",
156
+ archiveStatus: "all",
157
+ });
160
158
  const match = all.find((c) => c.id.startsWith(conversationId!));
161
159
  if (match) {
162
160
  conversation = match;
@@ -27,6 +27,7 @@ import {
27
27
  } from "../../persistence/conversation-crud.js";
28
28
  import { resolveConversationId } from "../../persistence/conversation-key-store.js";
29
29
  import {
30
+ type ConversationListFilter,
30
31
  countConversations,
31
32
  countUnreadConversations,
32
33
  listConversations,
@@ -200,18 +201,19 @@ function handleListConversations({ queryParams = {} }: RouteHandlerArgs) {
200
201
  ? queryParams.originChannel
201
202
  : undefined;
202
203
 
203
- let rows = listConversations(
204
- limit,
205
- conversationType,
206
- offset,
207
- archiveStatus,
208
- originChannel,
209
- );
210
- const totalCount = countConversations(
204
+ const groupId =
205
+ queryParams.groupId !== undefined && queryParams.groupId !== ""
206
+ ? queryParams.groupId
207
+ : undefined;
208
+
209
+ const filter: ConversationListFilter = {
211
210
  conversationType,
212
211
  archiveStatus,
213
212
  originChannel,
214
- );
213
+ groupId,
214
+ };
215
+ let rows = listConversations({ ...filter, limit, offset });
216
+ const totalCount = countConversations(filter);
215
217
 
216
218
  // On the first page, ensure all pinned conversations are included
217
219
  // even if they fall outside the paginated window. Pinned injection is
@@ -219,11 +221,19 @@ function handleListConversations({ queryParams = {} }: RouteHandlerArgs) {
219
221
  // rows in archive-time order, not pin order. Also skipped for
220
222
  // channel-scoped queries — those return only items matching the
221
223
  // requested origin channel; pinned items render in their own section.
224
+ //
225
+ // Skipped for group-scoped queries for the same reason: a caller asking
226
+ // for one group gets that group, and a client that fetches the Pinned
227
+ // section via `groupId=system:pinned` has no use for rows appended to
228
+ // some other group's page. This is the compatibility shim for clients
229
+ // that still read Pinned out of the unfiltered list; it goes away once
230
+ // every section fetches its own group.
222
231
  if (
223
232
  offset === 0 &&
224
233
  conversationType === "standard" &&
225
234
  archiveStatus === "active" &&
226
- originChannel === undefined
235
+ originChannel === undefined &&
236
+ groupId === undefined
227
237
  ) {
228
238
  const pinned = listPinnedConversations(archiveStatus);
229
239
  const seen = new Set(rows.map((c) => c.id));
@@ -460,6 +470,13 @@ export const ROUTES: RouteDefinition[] = [
460
470
  enum: [...CHANNEL_IDS],
461
471
  },
462
472
  },
473
+ {
474
+ name: "groupId",
475
+ type: "string",
476
+ required: false,
477
+ description:
478
+ 'Filter to a single group, so each sidebar section can load independently of the paginated list. Pass "system:all" for conversations in no group, "system:pinned" for the Pinned section, or a custom group id. A group-scoped request is ordered by the user\'s arrangement (display order, then recency) and never has pinned rows appended to it. Omit to span every group.',
479
+ },
463
480
  ],
464
481
  responseBody: listConversationsResponseSchema,
465
482
  handler: handleListConversations,
@@ -50,7 +50,10 @@ function getDebugInfo() {
50
50
  // Debug view counts every standard conversation, archived or not, so the
51
51
  // diagnostics report doesn't undercount after the route-level default
52
52
  // moved to "active".
53
- const conversationCount = countConversations("standard", "all");
53
+ const conversationCount = countConversations({
54
+ conversationType: "standard",
55
+ archiveStatus: "all",
56
+ });
54
57
  const memoryItemCount = getMemoryItemCount();
55
58
  const dbSizeBytes = getDatabaseSizeBytes();
56
59
 
@@ -93,9 +93,15 @@ export async function executeConversationMoveToGroup(
93
93
  `Moving into ${group.name} demotes it out of the Recents listing.`,
94
94
  );
95
95
  }
96
- if (conversation.conversationType !== "standard") {
96
+ // Only Pinned and custom groups surface a hidden conversation. Recents is a
97
+ // removal target, and the Scheduled/Background groups are demotions, so
98
+ // claiming any of them made the conversation visible would misreport the
99
+ // outcome to the model and the user.
100
+ const surfacesConversation =
101
+ group.id === "system:pinned" || !group.id.startsWith("system:");
102
+ if (conversation.conversationType !== "standard" && surfacesConversation) {
97
103
  notes.push(
98
- `Note: this is a ${conversation.conversationType} conversation, which stays hidden from the sidebar regardless of group.`,
104
+ `Note: this is a ${conversation.conversationType} conversation, so filing it here also surfaces it into the sidebar.`,
99
105
  );
100
106
  }
101
107