@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.
Files changed (32) hide show
  1. package/node_modules/@vellumai/gateway-client/src/index.ts +4 -0
  2. package/node_modules/@vellumai/gateway-client/src/verification-session-contract.ts +91 -6
  3. package/openapi.yaml +4 -4
  4. package/package.json +1 -1
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  6. package/src/__tests__/call-start-guardian-guard.test.ts +18 -4
  7. package/src/__tests__/channel-guardian.test.ts +55 -25
  8. package/src/__tests__/conversation-list-recency-ordering.test.ts +104 -0
  9. package/src/__tests__/conversation-placement-promotion.test.ts +16 -42
  10. package/src/__tests__/conversation-retry-route.test.ts +1 -1
  11. package/src/__tests__/conversation-summarize-route.test.ts +1 -1
  12. package/src/__tests__/helpers/gateway-verification-sessions-stub.ts +14 -1
  13. package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +61 -57
  14. package/src/__tests__/http-conversation-lineage.test.ts +2 -5
  15. package/src/__tests__/introduction-card-resolver.test.ts +6 -1
  16. package/src/__tests__/slack-inbound-verification.test.ts +4 -1
  17. package/src/__tests__/trusted-contact-multichannel.test.ts +4 -1
  18. package/src/approvals/guardian-request-resolvers.ts +22 -4
  19. package/src/channels/__tests__/gateway-verification-sessions.test.ts +3 -3
  20. package/src/channels/gateway-verification-sessions.ts +7 -2
  21. package/src/daemon/handlers/config-channels.ts +4 -1
  22. package/src/persistence/conversation-crud.ts +18 -27
  23. package/src/persistence/conversation-group-migration.ts +1 -1
  24. package/src/persistence/conversation-queries.ts +1 -26
  25. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +27 -13
  26. package/src/runtime/routes/conversation-management-routes.ts +6 -7
  27. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +7 -3
  28. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +14 -11
  29. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +14 -17
  30. package/src/runtime/verification-outbound-actions.ts +16 -9
  31. package/src/tools/calls/call-start.ts +8 -5
  32. 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
- expectedExternalUserId: z.string().optional(),
197
- expectedChatId: z.string().optional(),
198
- expectedPhoneE164: z.string().optional(),
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` / `ifNoneActive`) fails. Only
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 = ChannelOnlyIpcParamsSchema;
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: Reorder conversations
9297
- description: Batch-update display order and pin state for conversations.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608070306.b97cdd6",
3
+ "version": "0.11.2-dev.202608070534.d55eaf1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- batchSetDisplayOrders: mock(() => {}),
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
- findActiveSession: async (channel: string) =>
44
- findActiveSessionMockActive
45
- ? activeVoiceSession
46
- : realGatewaySessionsModule.findActiveSession(channel),
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("creating a new outbound session auto-revokes prior pending/awaiting sessions", async () => {
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: "+15551234567",
1647
- destinationAddress: "+15551234567",
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
- const first = await serviceFindActiveSession("phone");
1651
- expect(first).not.toBeNull();
1652
- expect(first!.id).toBe(firstId);
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
- // Create a second session — first should be revoked
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: "+15559876543",
1658
- destinationAddress: "+15559876543",
1673
+ expectedPhoneE164: "+15555550102",
1674
+ destinationAddress: "+15555550102",
1659
1675
  });
1660
1676
 
1661
- const second = await serviceFindActiveSession("phone");
1662
- expect(second).not.toBeNull();
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 match via expectedExternalUserId ──
1785
+ // ── Voice identity is the number ──
1776
1786
 
1777
- test("Voice identity match succeeds via expectedExternalUserId", async () => {
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: "voice-user-42",
1781
- expectedPhoneE164: "+15551234567",
1782
- destinationAddress: "+15551234567",
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
- "voice-user-42",
1790
- "voice-chat-1",
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
- batchSetDisplayOrders,
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
- batchSetDisplayOrders([
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
- batchSetDisplayOrders([
71
- { id: conv.id, displayOrder: 0, groupId: "system:pinned" },
72
- ]);
68
+ batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
73
69
 
74
- batchSetDisplayOrders([
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
- batchSetDisplayOrders([
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
- batchSetDisplayOrders([
98
- { id: conv.id, displayOrder: 0, groupId: group.id },
99
- ]);
89
+ batchSetConversationPlacement([{ id: conv.id, groupId: group.id }]);
100
90
 
101
- batchSetDisplayOrders([
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
- batchSetDisplayOrders([
114
- { id: conv.id, displayOrder: 0, groupId: group.id },
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
- batchSetDisplayOrders([
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
- batchSetDisplayOrders([
134
- { id: conv.id, displayOrder: null, groupId: "system:background" },
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
- batchSetDisplayOrders([
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
- batchSetDisplayOrders([
172
- { id: conv.id, displayOrder: 0, groupId: "system:pinned" },
173
- ]);
151
+ batchSetConversationPlacement([{ id: conv.id, groupId: "system:pinned" }]);
174
152
 
175
- batchSetDisplayOrders([
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
- batchSetDisplayOrders([
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
- batchSetDisplayOrders: () => {},
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
- batchSetDisplayOrders: () => {},
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
- findActiveSession: async () => {
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 () => {