@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 +10 -0
- package/package.json +1 -1
- package/src/__tests__/conversation-group-tools.test.ts +32 -2
- package/src/__tests__/conversation-placement-promotion.test.ts +198 -0
- package/src/__tests__/custom-group-listing-visibility.test.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/daemon/handlers/conversation-history.ts +1 -1
- package/src/persistence/conversation-crud.ts +47 -11
- package/src/persistence/conversation-plugin-facade.ts +14 -3
- package/src/persistence/conversation-queries.ts +131 -31
- package/src/persistence/conversation-types.ts +10 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +64 -18
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +254 -0
- package/src/runtime/routes/conversation-cli-routes.ts +10 -12
- package/src/runtime/routes/conversation-list-routes.ts +27 -10
- package/src/runtime/routes/debug-routes.ts +4 -1
- package/src/tools/conversation-groups/move_to_group.ts +8 -2
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
|
@@ -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("
|
|
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("
|
|
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(
|
|
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 ??
|
|
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 ===
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
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 ===
|
|
4080
|
+
safeGroupId === PINNED_GROUP_ID ? 1 : 0,
|
|
4059
4081
|
safeGroupId,
|
|
4060
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
104
|
+
`Note: this is a ${conversation.conversationType} conversation, so filing it here also surfaces it into the sidebar.`,
|
|
99
105
|
);
|
|
100
106
|
}
|
|
101
107
|
|