@vellumai/assistant 0.11.2-dev.202608070306.b97cdd6 → 0.11.2-dev.202608070534.d55eaf1
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/node_modules/@vellumai/gateway-client/src/index.ts +4 -0
- package/node_modules/@vellumai/gateway-client/src/verification-session-contract.ts +91 -6
- package/openapi.yaml +4 -4
- package/package.json +1 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
- package/src/__tests__/call-start-guardian-guard.test.ts +18 -4
- package/src/__tests__/channel-guardian.test.ts +55 -25
- package/src/__tests__/conversation-list-recency-ordering.test.ts +104 -0
- package/src/__tests__/conversation-placement-promotion.test.ts +16 -42
- package/src/__tests__/conversation-retry-route.test.ts +1 -1
- package/src/__tests__/conversation-summarize-route.test.ts +1 -1
- package/src/__tests__/helpers/gateway-verification-sessions-stub.ts +14 -1
- package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +61 -57
- package/src/__tests__/http-conversation-lineage.test.ts +2 -5
- package/src/__tests__/introduction-card-resolver.test.ts +6 -1
- package/src/__tests__/slack-inbound-verification.test.ts +4 -1
- package/src/__tests__/trusted-contact-multichannel.test.ts +4 -1
- package/src/approvals/guardian-request-resolvers.ts +22 -4
- package/src/channels/__tests__/gateway-verification-sessions.test.ts +3 -3
- package/src/channels/gateway-verification-sessions.ts +7 -2
- package/src/daemon/handlers/config-channels.ts +4 -1
- package/src/persistence/conversation-crud.ts +18 -27
- package/src/persistence/conversation-group-migration.ts +1 -1
- package/src/persistence/conversation-queries.ts +1 -26
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +27 -13
- package/src/runtime/routes/conversation-management-routes.ts +6 -7
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +7 -3
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +14 -11
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +14 -17
- package/src/runtime/verification-outbound-actions.ts +16 -9
- package/src/tools/calls/call-start.ts +8 -5
- package/src/tools/conversation-groups/move_to_group.ts +2 -10
|
@@ -157,6 +157,8 @@ export {
|
|
|
157
157
|
hashVerificationSecret,
|
|
158
158
|
IdentityBindingStatusSchema,
|
|
159
159
|
ResolveBootstrapSessionIpcParamsSchema,
|
|
160
|
+
bindsSameIdentity,
|
|
161
|
+
boundIdentity,
|
|
160
162
|
RevokePendingSessionsIpcParamsSchema,
|
|
161
163
|
SessionLookupIpcResponseSchema,
|
|
162
164
|
SessionMutationIpcResponseSchema,
|
|
@@ -172,6 +174,7 @@ export {
|
|
|
172
174
|
|
|
173
175
|
export type {
|
|
174
176
|
BindSessionIdentityIpcParams,
|
|
177
|
+
BoundIdentity,
|
|
175
178
|
CountRecentSendsIpcParams,
|
|
176
179
|
CountRecentSendsIpcResponse,
|
|
177
180
|
CreateInboundSessionIpcParams,
|
|
@@ -183,6 +186,7 @@ export type {
|
|
|
183
186
|
FindActiveSessionIpcParams,
|
|
184
187
|
GetPendingSessionIpcParams,
|
|
185
188
|
IdentityBindingStatus,
|
|
189
|
+
IdentityBoundSession,
|
|
186
190
|
ResolveBootstrapSessionIpcParams,
|
|
187
191
|
RevokePendingSessionsIpcParams,
|
|
188
192
|
SessionLookupIpcResponse,
|
|
@@ -113,6 +113,78 @@ export const VerificationSessionSchema = z.object({
|
|
|
113
113
|
|
|
114
114
|
export type VerificationSessionWire = z.infer<typeof VerificationSessionSchema>;
|
|
115
115
|
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Bound identity (which of the three identity columns decides who may redeem)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** The identity fields the rules read. Callers may pass a full session row. */
|
|
121
|
+
export interface IdentityBoundSession {
|
|
122
|
+
expectedExternalUserId?: string | null;
|
|
123
|
+
expectedChatId?: string | null;
|
|
124
|
+
expectedPhoneE164?: string | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The identity a session redeems on.
|
|
129
|
+
*
|
|
130
|
+
* `chatId` is compared against the actor's conversation and everything else
|
|
131
|
+
* against the actor themselves, which is why the field travels with the value.
|
|
132
|
+
*/
|
|
133
|
+
export interface BoundIdentity {
|
|
134
|
+
field: "phoneE164" | "externalUserId" | "chatId";
|
|
135
|
+
value: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The one identity a session is bound to, or null when it is bound to none
|
|
140
|
+
* (an inbound challenge, or a bootstrap session before its deep link is
|
|
141
|
+
* redeemed).
|
|
142
|
+
*
|
|
143
|
+
* This lives in the contract because more than one side has to agree on it:
|
|
144
|
+
* the gateway consume path decides who may redeem a code, and the supersede
|
|
145
|
+
* scope decides whose earlier codes a fresh mint kills. Those two disagreeing
|
|
146
|
+
* is a silent one-time-code bug, so the rule is stated once.
|
|
147
|
+
*
|
|
148
|
+
* Precedence, most to least specific:
|
|
149
|
+
*
|
|
150
|
+
* 1. `expectedPhoneE164` (a voice session's subject is the number itself).
|
|
151
|
+
* 2. `expectedExternalUserId` (the person).
|
|
152
|
+
* 3. `expectedChatId` (the conversation), and only when nothing identifies a
|
|
153
|
+
* person. A chat id can be shared (a Slack channel, a Telegram group), so
|
|
154
|
+
* on its own it would let any participant satisfy the binding. It counts
|
|
155
|
+
* only where there is no better answer.
|
|
156
|
+
*/
|
|
157
|
+
export function boundIdentity(
|
|
158
|
+
session: IdentityBoundSession,
|
|
159
|
+
): BoundIdentity | null {
|
|
160
|
+
// Presence, not truthiness. An empty string is a set-but-useless identity,
|
|
161
|
+
// and treating it as absent would report the session unbound, which the
|
|
162
|
+
// consume path reads as "no identity to check" and admits anyone holding
|
|
163
|
+
// the code. Kept as a value it can compare against and fail instead.
|
|
164
|
+
if (session.expectedPhoneE164 != null) {
|
|
165
|
+
return { field: "phoneE164", value: session.expectedPhoneE164 };
|
|
166
|
+
}
|
|
167
|
+
if (session.expectedExternalUserId != null) {
|
|
168
|
+
return { field: "externalUserId", value: session.expectedExternalUserId };
|
|
169
|
+
}
|
|
170
|
+
if (session.expectedChatId != null) {
|
|
171
|
+
return { field: "chatId", value: session.expectedChatId };
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether two sessions are bound to the same identity. Two sessions bound to
|
|
178
|
+
* no identity are not the same: bootstrap sessions are claimed by id, so an
|
|
179
|
+
* unbound mint must not sweep up every other unbound row.
|
|
180
|
+
*/
|
|
181
|
+
export function bindsSameIdentity(
|
|
182
|
+
a: BoundIdentity | null,
|
|
183
|
+
b: BoundIdentity | null,
|
|
184
|
+
): boolean {
|
|
185
|
+
return a !== null && b !== null && a.field === b.field && a.value === b.value;
|
|
186
|
+
}
|
|
187
|
+
|
|
116
188
|
// ---------------------------------------------------------------------------
|
|
117
189
|
// IPC methods
|
|
118
190
|
// ---------------------------------------------------------------------------
|
|
@@ -193,9 +265,12 @@ export type CreateInboundSessionIpcResponse = z.infer<
|
|
|
193
265
|
/** Request for `verification_sessions_create_outbound`. */
|
|
194
266
|
export const CreateOutboundSessionIpcParamsSchema = z.object({
|
|
195
267
|
channel: z.string().min(1),
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
268
|
+
// An identity field is either absent or a real value. An empty string is
|
|
269
|
+
// neither, and a session bound to one can never be redeemed by anybody,
|
|
270
|
+
// so reject it at the boundary rather than mint a dead code.
|
|
271
|
+
expectedExternalUserId: z.string().min(1).optional(),
|
|
272
|
+
expectedChatId: z.string().min(1).optional(),
|
|
273
|
+
expectedPhoneE164: z.string().min(1).optional(),
|
|
199
274
|
identityBindingStatus: IdentityBindingStatusSchema.optional(),
|
|
200
275
|
destinationAddress: z.string().optional(),
|
|
201
276
|
codeDigits: z.number().int().positive().optional(),
|
|
@@ -211,7 +286,6 @@ export const CreateOutboundSessionIpcParamsSchema = z.object({
|
|
|
211
286
|
requireSourceSessionPending: z.string().min(1).optional(),
|
|
212
287
|
// Mint only if the channel has no active (pending_bootstrap /
|
|
213
288
|
// awaiting_response, non-expired) session.
|
|
214
|
-
ifNoneActive: z.boolean().optional(),
|
|
215
289
|
// Sender-scoped variant: mint unless the channel's active session is bound
|
|
216
290
|
// to this expectedExternalUserId (a different sender may supersede).
|
|
217
291
|
ifNoneActiveForExternalUserId: z.string().min(1).optional(),
|
|
@@ -240,7 +314,8 @@ export type CreateOutboundSessionIpcResponse = z.infer<
|
|
|
240
314
|
|
|
241
315
|
/**
|
|
242
316
|
* Conflict marker returned by `verification_sessions_create_outbound` when a
|
|
243
|
-
* claim guard (`requireSourceSessionPending` /
|
|
317
|
+
* claim guard (`requireSourceSessionPending` /
|
|
318
|
+
* `ifNoneActiveForExternalUserId`) fails. Only
|
|
244
319
|
* reachable when a guard was passed — legacy callers never see this shape.
|
|
245
320
|
*/
|
|
246
321
|
export const CreateOutboundSessionConflictSchema = z.object({
|
|
@@ -277,7 +352,17 @@ export type GetPendingSessionIpcParams = z.infer<
|
|
|
277
352
|
* Request for `verification_sessions_find_active` (statuses
|
|
278
353
|
* `pending_bootstrap`/`awaiting_response`, newest first).
|
|
279
354
|
*/
|
|
280
|
-
export const FindActiveSessionIpcParamsSchema =
|
|
355
|
+
export const FindActiveSessionIpcParamsSchema =
|
|
356
|
+
ChannelOnlyIpcParamsSchema.extend({
|
|
357
|
+
/**
|
|
358
|
+
* Narrows the lookup to one session. A channel can carry several live
|
|
359
|
+
* sessions at once, one per person verifying plus the guardian's own flow,
|
|
360
|
+
* so a caller that means a particular one has to say which. Omitted, the
|
|
361
|
+
* lookup returns whoever started most recently.
|
|
362
|
+
*/
|
|
363
|
+
expectedExternalUserId: z.string().min(1).optional(),
|
|
364
|
+
verificationPurpose: VerificationPurposeSchema.optional(),
|
|
365
|
+
});
|
|
281
366
|
|
|
282
367
|
export type FindActiveSessionIpcParams = z.infer<
|
|
283
368
|
typeof FindActiveSessionIpcParamsSchema
|
package/openapi.yaml
CHANGED
|
@@ -9293,8 +9293,10 @@ paths:
|
|
|
9293
9293
|
/v1/conversations/reorder:
|
|
9294
9294
|
post:
|
|
9295
9295
|
operationId: conversations_reorder_post
|
|
9296
|
-
summary:
|
|
9297
|
-
description:
|
|
9296
|
+
summary: Move conversations between sections
|
|
9297
|
+
description:
|
|
9298
|
+
Batch-update which group holds each conversation, and its pin state. Lists are ordered by recency, so this
|
|
9299
|
+
sets placement, not order.
|
|
9298
9300
|
tags:
|
|
9299
9301
|
- conversations
|
|
9300
9302
|
requestBody:
|
|
@@ -9311,8 +9313,6 @@ paths:
|
|
|
9311
9313
|
properties:
|
|
9312
9314
|
conversationId:
|
|
9313
9315
|
type: string
|
|
9314
|
-
displayOrder:
|
|
9315
|
-
type: number
|
|
9316
9316
|
isPinned:
|
|
9317
9317
|
type: boolean
|
|
9318
9318
|
groupId:
|
package/package.json
CHANGED
|
@@ -61,7 +61,7 @@ mock.module("../persistence/conversation-crud.js", () => ({
|
|
|
61
61
|
getConversationPersistedSeq: () => null,
|
|
62
62
|
addMessage: mock(() => ({ id: "msg-1" })),
|
|
63
63
|
archiveConversation: mock(() => true),
|
|
64
|
-
|
|
64
|
+
batchSetConversationPlacement: mock(() => {}),
|
|
65
65
|
createConversation: (opts: { conversationType: string }) => {
|
|
66
66
|
createdConversations.push(opts);
|
|
67
67
|
return { id: "conv-1", ...opts };
|
|
@@ -40,10 +40,24 @@ const realGatewaySessionsModule = {
|
|
|
40
40
|
};
|
|
41
41
|
mock.module("../channels/gateway-verification-sessions.js", () => ({
|
|
42
42
|
...realGatewaySessionsModule,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// Honours the caller's filter: the guard asks for the number it is about
|
|
44
|
+
// to dial, so a session for a different number must read as absent.
|
|
45
|
+
findActiveSession: async (
|
|
46
|
+
channel: string,
|
|
47
|
+
filter?: { expectedExternalUserId?: string },
|
|
48
|
+
) => {
|
|
49
|
+
if (!findActiveSessionMockActive) {
|
|
50
|
+
return realGatewaySessionsModule.findActiveSession(channel, filter);
|
|
51
|
+
}
|
|
52
|
+
const wanted = filter?.expectedExternalUserId;
|
|
53
|
+
if (
|
|
54
|
+
wanted !== undefined &&
|
|
55
|
+
activeVoiceSession?.expectedPhoneE164 !== wanted
|
|
56
|
+
) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return activeVoiceSession;
|
|
60
|
+
},
|
|
47
61
|
}));
|
|
48
62
|
|
|
49
63
|
const { executeCallStart } = await import("../tools/calls/call-start.js");
|
|
@@ -1640,32 +1640,42 @@ describe("outbound verification sessions", () => {
|
|
|
1640
1640
|
|
|
1641
1641
|
// ── Auto-revoke of prior sessions ──
|
|
1642
1642
|
|
|
1643
|
-
test("
|
|
1643
|
+
test("a second code for the same number revokes the first", async () => {
|
|
1644
1644
|
const { sessionId: firstId } = await createOutboundSession({
|
|
1645
1645
|
channel: "phone",
|
|
1646
|
-
expectedPhoneE164: "+
|
|
1647
|
-
destinationAddress: "+
|
|
1646
|
+
expectedPhoneE164: "+15555550101",
|
|
1647
|
+
destinationAddress: "+15555550101",
|
|
1648
|
+
});
|
|
1649
|
+
const { sessionId: secondId } = await createOutboundSession({
|
|
1650
|
+
channel: "phone",
|
|
1651
|
+
expectedPhoneE164: "+15555550101",
|
|
1652
|
+
destinationAddress: "+15555550101",
|
|
1648
1653
|
});
|
|
1649
1654
|
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1655
|
+
// Read by id rather than through the unfiltered lookup: two live
|
|
1656
|
+
// sessions minted in the same millisecond have no defined order, and
|
|
1657
|
+
// what matters here is each row's status, not which one is "latest".
|
|
1658
|
+
expect(getSessionById(firstId)!.status).toBe("revoked");
|
|
1659
|
+
expect(getSessionById(secondId)!.status).toBe("awaiting_response");
|
|
1660
|
+
});
|
|
1653
1661
|
|
|
1654
|
-
|
|
1662
|
+
test("a code for a different number leaves the first alone", async () => {
|
|
1663
|
+
// Two numbers are two people. Verifying one must not silently kill the
|
|
1664
|
+
// other's code, which they would learn about only by trying to use it
|
|
1665
|
+
// and being told it is "invalid or has expired".
|
|
1666
|
+
const { sessionId: firstId } = await createOutboundSession({
|
|
1667
|
+
channel: "phone",
|
|
1668
|
+
expectedPhoneE164: "+15555550101",
|
|
1669
|
+
destinationAddress: "+15555550101",
|
|
1670
|
+
});
|
|
1655
1671
|
const { sessionId: secondId } = await createOutboundSession({
|
|
1656
1672
|
channel: "phone",
|
|
1657
|
-
expectedPhoneE164: "+
|
|
1658
|
-
destinationAddress: "+
|
|
1673
|
+
expectedPhoneE164: "+15555550102",
|
|
1674
|
+
destinationAddress: "+15555550102",
|
|
1659
1675
|
});
|
|
1660
1676
|
|
|
1661
|
-
|
|
1662
|
-
expect(
|
|
1663
|
-
expect(second!.id).toBe(secondId);
|
|
1664
|
-
|
|
1665
|
-
// First session should no longer be findable as active
|
|
1666
|
-
const firstRow = getSessionById(firstId);
|
|
1667
|
-
expect(firstRow).not.toBeNull();
|
|
1668
|
-
expect(firstRow!.status).toBe("revoked");
|
|
1677
|
+
expect(getSessionById(firstId)!.status).toBe("awaiting_response");
|
|
1678
|
+
expect(getSessionById(secondId)!.status).toBe("awaiting_response");
|
|
1669
1679
|
});
|
|
1670
1680
|
|
|
1671
1681
|
// ── findActiveSession returns correct session ──
|
|
@@ -1772,26 +1782,46 @@ describe("outbound verification sessions", () => {
|
|
|
1772
1782
|
expect(result.success).toBe(true);
|
|
1773
1783
|
});
|
|
1774
1784
|
|
|
1775
|
-
// ── Voice identity
|
|
1785
|
+
// ── Voice identity is the number ──
|
|
1776
1786
|
|
|
1777
|
-
test("
|
|
1787
|
+
test("a voice session is redeemed by the number it was sent to", async () => {
|
|
1788
|
+
// Voice knows the caller by one thing, so the consume path receives that
|
|
1789
|
+
// number as both actor axes. Every voice mint records it in both
|
|
1790
|
+
// identity columns, and either way the session is bound to the number.
|
|
1778
1791
|
const { secret } = await createOutboundSession({
|
|
1779
1792
|
channel: "phone",
|
|
1780
|
-
expectedExternalUserId: "
|
|
1781
|
-
expectedPhoneE164: "+
|
|
1782
|
-
destinationAddress: "+
|
|
1793
|
+
expectedExternalUserId: "+15555550101",
|
|
1794
|
+
expectedPhoneE164: "+15555550101",
|
|
1795
|
+
destinationAddress: "+15555550101",
|
|
1783
1796
|
});
|
|
1784
1797
|
|
|
1785
|
-
// Actor matches expectedExternalUserId
|
|
1786
1798
|
const result = await validateAndConsumeVerification(
|
|
1787
1799
|
"phone",
|
|
1788
1800
|
secret,
|
|
1789
|
-
"
|
|
1790
|
-
"
|
|
1801
|
+
"+15555550101",
|
|
1802
|
+
"+15555550101",
|
|
1791
1803
|
);
|
|
1792
1804
|
|
|
1793
1805
|
expect(result.success).toBe(true);
|
|
1794
1806
|
});
|
|
1807
|
+
|
|
1808
|
+
test("a voice session refuses a caller on a different number", async () => {
|
|
1809
|
+
const { secret } = await createOutboundSession({
|
|
1810
|
+
channel: "phone",
|
|
1811
|
+
expectedExternalUserId: "+15555550101",
|
|
1812
|
+
expectedPhoneE164: "+15555550101",
|
|
1813
|
+
destinationAddress: "+15555550101",
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
const result = await validateAndConsumeVerification(
|
|
1817
|
+
"phone",
|
|
1818
|
+
secret,
|
|
1819
|
+
"+15555550102",
|
|
1820
|
+
"+15555550102",
|
|
1821
|
+
);
|
|
1822
|
+
|
|
1823
|
+
expect(result.success).toBe(false);
|
|
1824
|
+
});
|
|
1795
1825
|
});
|
|
1796
1826
|
|
|
1797
1827
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { createConversation } from "../persistence/conversation-crud.js";
|
|
5
|
+
import { ensureGroupMigration } from "../persistence/conversation-group-migration.js";
|
|
6
|
+
import {
|
|
7
|
+
listConversations,
|
|
8
|
+
listPinnedConversations,
|
|
9
|
+
} from "../persistence/conversation-queries.js";
|
|
10
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
11
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
12
|
+
import { createGroup } from "../persistence/group-crud.js";
|
|
13
|
+
|
|
14
|
+
await initializeDb();
|
|
15
|
+
ensureGroupMigration();
|
|
16
|
+
|
|
17
|
+
function getRawDb(): Database {
|
|
18
|
+
return (getDb() as unknown as { $client: Database }).$client;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every conversation list orders by recency, pinned and custom groups
|
|
23
|
+
* included.
|
|
24
|
+
*
|
|
25
|
+
* `display_order` is a live column and some rows carry a value in it, so
|
|
26
|
+
* "nothing writes it" is not enough to make the order consistent: a read that
|
|
27
|
+
* consulted it would serve those rows in an arrangement no user can change,
|
|
28
|
+
* while every other surface shows recency.
|
|
29
|
+
*
|
|
30
|
+
* Each test sets `display_order` to contradict recency, so a read that
|
|
31
|
+
* consults it fails here rather than passing by coincidence.
|
|
32
|
+
*/
|
|
33
|
+
describe("conversation list ordering ignores display_order", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
const raw = getRawDb();
|
|
36
|
+
raw.run("DELETE FROM conversations");
|
|
37
|
+
raw.run("DELETE FROM conversation_groups WHERE is_system_group = 0");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Give a row a `display_order` and a recency stamp that disagree. */
|
|
41
|
+
function arrange(id: string, displayOrder: number, lastMessageAt: number) {
|
|
42
|
+
getRawDb().run(
|
|
43
|
+
"UPDATE conversations SET display_order = ?, last_message_at = ? WHERE id = ?",
|
|
44
|
+
[displayOrder, lastMessageAt, id],
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("a custom group orders by recency, ignoring display_order", () => {
|
|
49
|
+
const groupId = createGroup("Briefs").id;
|
|
50
|
+
createConversation({ id: "conv-old", source: "user", groupId });
|
|
51
|
+
createConversation({ id: "conv-new", source: "user", groupId });
|
|
52
|
+
// `display_order` says old-then-new; recency says the opposite.
|
|
53
|
+
arrange("conv-old", 0, 1_000);
|
|
54
|
+
arrange("conv-new", 1, 9_000);
|
|
55
|
+
|
|
56
|
+
expect(listConversations({ groupId }).map((c) => c.id)).toEqual([
|
|
57
|
+
"conv-new",
|
|
58
|
+
"conv-old",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("the pinned group orders by recency, ignoring display_order", () => {
|
|
63
|
+
createConversation({
|
|
64
|
+
id: "pin-old",
|
|
65
|
+
source: "user",
|
|
66
|
+
groupId: "system:pinned",
|
|
67
|
+
});
|
|
68
|
+
createConversation({
|
|
69
|
+
id: "pin-new",
|
|
70
|
+
source: "user",
|
|
71
|
+
groupId: "system:pinned",
|
|
72
|
+
});
|
|
73
|
+
arrange("pin-old", 0, 1_000);
|
|
74
|
+
arrange("pin-new", 1, 9_000);
|
|
75
|
+
|
|
76
|
+
expect(
|
|
77
|
+
listConversations({ groupId: "system:pinned" }).map((c) => c.id),
|
|
78
|
+
).toEqual(["pin-new", "pin-old"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/* The page-one pinned injection reads through its own query, so it needs
|
|
82
|
+
the same guarantee: one section must not have two orderings depending on
|
|
83
|
+
which read served it. */
|
|
84
|
+
test("listPinnedConversations orders by recency too", () => {
|
|
85
|
+
createConversation({
|
|
86
|
+
id: "pin-old",
|
|
87
|
+
source: "user",
|
|
88
|
+
groupId: "system:pinned",
|
|
89
|
+
});
|
|
90
|
+
createConversation({
|
|
91
|
+
id: "pin-new",
|
|
92
|
+
source: "user",
|
|
93
|
+
groupId: "system:pinned",
|
|
94
|
+
});
|
|
95
|
+
getRawDb().run("UPDATE conversations SET is_pinned = 1");
|
|
96
|
+
arrange("pin-old", 0, 1_000);
|
|
97
|
+
arrange("pin-new", 1, 9_000);
|
|
98
|
+
|
|
99
|
+
expect(listPinnedConversations().map((c) => c.id)).toEqual([
|
|
100
|
+
"pin-new",
|
|
101
|
+
"pin-old",
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
|
-
|
|
20
|
+
batchSetConversationPlacement,
|
|
21
21
|
createConversation,
|
|
22
22
|
} from "../persistence/conversation-crud.js";
|
|
23
23
|
import { listConversations } from "../persistence/conversation-queries.js";
|
|
@@ -58,22 +58,16 @@ describe("pinning a background conversation", () => {
|
|
|
58
58
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
59
59
|
expect(visibleTitles()).toEqual([]);
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
63
|
-
]);
|
|
61
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
|
|
64
62
|
|
|
65
63
|
expect(titlesInGroup("system:pinned")).toEqual(["bg-job"]);
|
|
66
64
|
});
|
|
67
65
|
|
|
68
66
|
test("unpinning lands it in Recents rather than hiding it again", () => {
|
|
69
67
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
70
|
-
|
|
71
|
-
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
72
|
-
]);
|
|
68
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
|
|
73
69
|
|
|
74
|
-
|
|
75
|
-
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
76
|
-
]);
|
|
70
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
|
|
77
71
|
|
|
78
72
|
expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
|
|
79
73
|
});
|
|
@@ -84,9 +78,7 @@ describe("filing a background conversation into a custom group", () => {
|
|
|
84
78
|
const group = createGroup("Car Chat");
|
|
85
79
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
86
80
|
|
|
87
|
-
|
|
88
|
-
{ id: conv.id, displayOrder: 0, groupId: group.id },
|
|
89
|
-
]);
|
|
81
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
|
|
90
82
|
|
|
91
83
|
expect(titlesInGroup(group.id)).toEqual(["bg-job"]);
|
|
92
84
|
});
|
|
@@ -94,13 +86,9 @@ describe("filing a background conversation into a custom group", () => {
|
|
|
94
86
|
test("removing it from the group lands it in Recents rather than losing it", () => {
|
|
95
87
|
const group = createGroup("Car Chat");
|
|
96
88
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
97
|
-
|
|
98
|
-
{ id: conv.id, displayOrder: 0, groupId: group.id },
|
|
99
|
-
]);
|
|
89
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
|
|
100
90
|
|
|
101
|
-
|
|
102
|
-
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
103
|
-
]);
|
|
91
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
|
|
104
92
|
|
|
105
93
|
expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
|
|
106
94
|
expect(visibleTitles()).toEqual(["bg-job"]);
|
|
@@ -110,12 +98,8 @@ describe("filing a background conversation into a custom group", () => {
|
|
|
110
98
|
const group = createGroup("Morning Briefs");
|
|
111
99
|
const conv = seedRoutedBackground("sched-job", "scheduled");
|
|
112
100
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
]);
|
|
116
|
-
batchSetDisplayOrders([
|
|
117
|
-
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
118
|
-
]);
|
|
101
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
|
|
102
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
|
|
119
103
|
|
|
120
104
|
expect(titlesInGroup("system:all")).toEqual(["sched-job"]);
|
|
121
105
|
});
|
|
@@ -125,13 +109,11 @@ describe("what promotion must not do", () => {
|
|
|
125
109
|
test("filing into system:background still demotes", () => {
|
|
126
110
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
127
111
|
// Promote first, so the demotion has something to undo.
|
|
128
|
-
|
|
129
|
-
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
130
|
-
]);
|
|
112
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
|
|
131
113
|
expect(titlesInGroup("system:pinned")).toEqual(["bg-job"]);
|
|
132
114
|
|
|
133
|
-
|
|
134
|
-
{ id: conv.id,
|
|
115
|
+
batchSetConversationPlacement([
|
|
116
|
+
{ id: conv.id, groupId: "system:background" },
|
|
135
117
|
]);
|
|
136
118
|
|
|
137
119
|
expect(visibleTitles()).toEqual([]);
|
|
@@ -149,9 +131,7 @@ describe("what promotion must not do", () => {
|
|
|
149
131
|
// system bucket.
|
|
150
132
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
151
133
|
|
|
152
|
-
|
|
153
|
-
{ id: conv.id, displayOrder: null, groupId: "system:all" },
|
|
154
|
-
]);
|
|
134
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:all" }]);
|
|
155
135
|
|
|
156
136
|
expect(visibleTitles()).toEqual([]);
|
|
157
137
|
});
|
|
@@ -168,13 +148,9 @@ describe("what promotion must not do", () => {
|
|
|
168
148
|
// came from, `surfaced_at` records that the user promoted it, and only
|
|
169
149
|
// an explicit demotion clears the latter.
|
|
170
150
|
const conv = seedRoutedBackground("bg-job", "background");
|
|
171
|
-
|
|
172
|
-
{ id: conv.id, displayOrder: 0, groupId: "system:pinned" },
|
|
173
|
-
]);
|
|
151
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
|
|
174
152
|
|
|
175
|
-
|
|
176
|
-
{ id: conv.id, displayOrder: null, isPinned: false },
|
|
177
|
-
]);
|
|
153
|
+
batchSetConversationPlacement([{ id: conv.id, isPinned: false }]);
|
|
178
154
|
|
|
179
155
|
expect(visibleTitles()).toEqual(["bg-job"]);
|
|
180
156
|
expect(titlesInGroup("system:all")).toEqual(["bg-job"]);
|
|
@@ -186,9 +162,7 @@ describe("what promotion must not do", () => {
|
|
|
186
162
|
const group = createGroup("Car Chat");
|
|
187
163
|
const conv = createConversation("plain-chat");
|
|
188
164
|
|
|
189
|
-
|
|
190
|
-
{ id: conv.id, displayOrder: 0, groupId: group.id },
|
|
191
|
-
]);
|
|
165
|
+
batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
|
|
192
166
|
|
|
193
167
|
const row = getDb()
|
|
194
168
|
.all(`SELECT surfaced_at FROM conversations WHERE id = '${conv.id}'`)
|
|
@@ -74,7 +74,7 @@ const getConversationMock = mock((id: string) =>
|
|
|
74
74
|
mock.module("../persistence/conversation-crud.js", () => ({
|
|
75
75
|
addMessage: async () => ({ id: "unused", deduplicated: false }),
|
|
76
76
|
archiveConversation: () => true,
|
|
77
|
-
|
|
77
|
+
batchSetConversationPlacement: () => {},
|
|
78
78
|
countConversationsByScheduleJobId: () => 0,
|
|
79
79
|
deleteConversation: () => ({ segmentIds: [], deletedSummaryIds: [] }),
|
|
80
80
|
extractImageSourcePaths: () => undefined,
|
|
@@ -53,7 +53,7 @@ const getConversationMock = mock((id: string) =>
|
|
|
53
53
|
mock.module("../persistence/conversation-crud.js", () => ({
|
|
54
54
|
addMessage: addMessageMock,
|
|
55
55
|
archiveConversation: () => true,
|
|
56
|
-
|
|
56
|
+
batchSetConversationPlacement: () => {},
|
|
57
57
|
countConversationsByScheduleJobId: () => 0,
|
|
58
58
|
deleteConversation: () => ({ segmentIds: [], deletedSummaryIds: [] }),
|
|
59
59
|
extractImageSourcePaths: () => undefined,
|
|
@@ -139,9 +139,22 @@ export function createGatewayVerificationSessionsStub(options: {
|
|
|
139
139
|
throwIfUnreachable("updateSessionDelivery");
|
|
140
140
|
calls.updateSessionDelivery.push(args);
|
|
141
141
|
},
|
|
142
|
-
|
|
142
|
+
// Honours the caller's `expectedExternalUserId` filter the way the
|
|
143
|
+
// gateway does. Returning the session for every filter would make a
|
|
144
|
+
// "someone else is verifying" test pass against a lookup that, against
|
|
145
|
+
// the real gateway, would have found nothing.
|
|
146
|
+
findActiveSession: async (...args: unknown[]) => {
|
|
143
147
|
calls.sessionReads.push("findActiveSession");
|
|
144
148
|
throwIfUnreachable("findActiveSession");
|
|
149
|
+
const wantedActor = (
|
|
150
|
+
args[1] as { expectedExternalUserId?: string } | undefined
|
|
151
|
+
)?.expectedExternalUserId;
|
|
152
|
+
if (
|
|
153
|
+
wantedActor !== undefined &&
|
|
154
|
+
state.activeSession?.expectedExternalUserId !== wantedActor
|
|
155
|
+
) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
145
158
|
return state.activeSession;
|
|
146
159
|
},
|
|
147
160
|
getPendingSession: async () => {
|