@vellumai/assistant 0.12.0-dev.202609110918.dd3fa5e → 0.12.0-dev.202609111417.417f7af

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.0-dev.202609110918.dd3fa5e",
3
+ "version": "0.12.0-dev.202609111417.417f7af",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -26,6 +26,15 @@ const messageRewrites: Array<{ messageId: string; content: string }> = [];
26
26
  /** messageId -> the conversation it belongs to, for the scoped lookup. */
27
27
  const messageOwners = new Map<string, string>();
28
28
  let conversationRow: { conversationType: string } | null = null;
29
+ /**
30
+ * Rows for specific ids, consulted before the catch-all `conversationRow`,
31
+ * so a test can hand the signal's source and the delivery's paired
32
+ * conversation different shapes.
33
+ */
34
+ const conversationRowsById = new Map<
35
+ string,
36
+ { conversationType: string; source?: string }
37
+ >();
29
38
  let conversationLookupShouldThrow = false;
30
39
  let messageAppendShouldThrow = false;
31
40
  let messageRewriteShouldThrow = false;
@@ -71,7 +80,7 @@ mock.module("../../persistence/conversation-crud.js", () => ({
71
80
  if (conversationLookupShouldThrow) {
72
81
  throw new Error("simulated conversation lookup failure");
73
82
  }
74
- return conversationRow;
83
+ return conversationRowsById.get(id) ?? conversationRow;
75
84
  },
76
85
  addMessage: async (
77
86
  conversationId: string,
@@ -164,6 +173,7 @@ beforeEach(() => {
164
173
  messageRewrites.length = 0;
165
174
  messageOwners.clear();
166
175
  conversationRow = null;
176
+ conversationRowsById.clear();
167
177
  conversationLookupShouldThrow = false;
168
178
  messageAppendShouldThrow = false;
169
179
  messageRewriteShouldThrow = false;
@@ -402,6 +412,139 @@ describe("writeHomeFeedItemForSignal", () => {
402
412
  expect(appendCalls[0]!.conversationId).toBe("paired-delivery-conv-id");
403
413
  });
404
414
 
415
+ describe("assistant-initiated thread deliveries", () => {
416
+ // Under the assistant-initiated-threads flag, conversation pairing
417
+ // promotes a background assistant.share into a standard conversation
418
+ // stamped `assistant_initiated`, which the sidebar's assistant section
419
+ // renders. That thread is the share's surface; a feed item for the
420
+ // same signal put it in the bell a second time.
421
+ const assistantThreadDelivery = () =>
422
+ makeVellumDelivery({ conversationId: "assistant-thread-1" });
423
+
424
+ function stampAssistantThread(): void {
425
+ conversationRowsById.set("assistant-thread-1", {
426
+ conversationType: "standard",
427
+ source: "assistant_initiated",
428
+ });
429
+ }
430
+
431
+ function shareFromHeartbeat(): NotificationSignal {
432
+ return makeSignal({
433
+ sourceChannel: "assistant_tool",
434
+ sourceEventName: "assistant.share",
435
+ sourceContextId: "conv-source-1",
436
+ contextPayload: { title: "Doctor checklist built" },
437
+ });
438
+ }
439
+
440
+ const shareDecision = () =>
441
+ makeDecision({
442
+ selectedChannels: ["vellum"],
443
+ renderedCopy: {
444
+ vellum: {
445
+ title: "Doctor checklist built",
446
+ body: "Your doctor-visit one-pager is ready.",
447
+ },
448
+ },
449
+ });
450
+
451
+ test("a background share promoted into an assistant thread does not mirror", async () => {
452
+ conversationRow = { conversationType: "background" };
453
+ stampAssistantThread();
454
+
455
+ const item = await writeHomeFeedItemForSignal(
456
+ shareFromHeartbeat(),
457
+ shareDecision(),
458
+ assistantThreadDelivery(),
459
+ );
460
+
461
+ expect(item).toBeNull();
462
+ expect(appendCalls).toHaveLength(0);
463
+ expect(messageAppends).toHaveLength(0);
464
+ });
465
+
466
+ test("the async-background hint does not override the assistant-thread suppression", async () => {
467
+ conversationRow = null;
468
+ stampAssistantThread();
469
+ const signal = makeSignal({
470
+ sourceChannel: "assistant_tool",
471
+ sourceEventName: "assistant.share",
472
+ sourceContextId: "heartbeat",
473
+ contextPayload: { title: "Bilbao solo trip is planned" },
474
+ attentionHints: {
475
+ requiresAction: false,
476
+ urgency: "medium",
477
+ isAsyncBackground: true,
478
+ visibleInSourceNow: false,
479
+ },
480
+ });
481
+
482
+ const item = await writeHomeFeedItemForSignal(
483
+ signal,
484
+ shareDecision(),
485
+ assistantThreadDelivery(),
486
+ );
487
+
488
+ expect(item).toBeNull();
489
+ expect(appendCalls).toHaveLength(0);
490
+ });
491
+
492
+ test("a share paired with an ordinary notification thread still mirrors", async () => {
493
+ // Flag off, or any producer that did not promote: pairing writes a
494
+ // `notification`-sourced row, which stays in Chats and the bell.
495
+ conversationRow = { conversationType: "background" };
496
+ conversationRowsById.set("assistant-thread-1", {
497
+ conversationType: "standard",
498
+ source: "notification",
499
+ });
500
+
501
+ const item = await writeHomeFeedItemForSignal(
502
+ shareFromHeartbeat(),
503
+ shareDecision(),
504
+ assistantThreadDelivery(),
505
+ );
506
+
507
+ expect(item).not.toBeNull();
508
+ expect(appendCalls).toHaveLength(1);
509
+ });
510
+
511
+ test("a guardian request keeps its bell item even when paired with an assistant thread", async () => {
512
+ // The bell is the request's canonical home: nothing else renders the
513
+ // pending card, so the assistant-thread suppression must not reach it.
514
+ conversationRow = { conversationType: "standard" };
515
+ stampAssistantThread();
516
+ const signal = makeSignal({
517
+ sourceChannel: "assistant_tool",
518
+ sourceEventName: "guardian.question",
519
+ contextPayload: {
520
+ title: "Approve this?",
521
+ questionText: "May I send the email?",
522
+ requestId: "req-1",
523
+ },
524
+ attentionHints: {
525
+ requiresAction: true,
526
+ urgency: "medium",
527
+ isAsyncBackground: false,
528
+ visibleInSourceNow: false,
529
+ },
530
+ });
531
+
532
+ const item = await writeHomeFeedItemForSignal(
533
+ signal,
534
+ makeDecision({
535
+ selectedChannels: ["vellum"],
536
+ renderedCopy: {
537
+ vellum: { title: "Approve this?", body: "May I send the email?" },
538
+ },
539
+ }),
540
+ assistantThreadDelivery(),
541
+ );
542
+
543
+ expect(item).not.toBeNull();
544
+ expect(appendCalls).toHaveLength(1);
545
+ });
546
+ });
547
+
405
548
  test("source conversation id wins over the paired delivery fallback when both are available", async () => {
406
549
  // When the producer's `sourceContextId` already points at a real
407
550
  // conversation (the canonical "where the work happened"), prefer it
@@ -23,7 +23,10 @@ import {
23
23
  getMessageById,
24
24
  updateMessageContent,
25
25
  } from "../persistence/conversation-crud.js";
26
- import { isBackgroundConversationType } from "../persistence/conversation-types.js";
26
+ import {
27
+ ASSISTANT_INITIATED_SOURCE,
28
+ isBackgroundConversationType,
29
+ } from "../persistence/conversation-types.js";
27
30
  import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
28
31
  import { getLogger } from "../util/logger.js";
29
32
  import { normalizeTitle, stripMarkdown } from "../util/short-title.js";
@@ -495,6 +498,18 @@ function deriveDetailPanelKind(
495
498
  * `isAsyncBackground` hint. `chat.assistant_reply` also mirrors: it is the
496
499
  * durable in-app record for the push sent after a user leaves a chat, while
497
500
  * retaining the normal interactive conversation as its navigation target.
501
+ *
502
+ * A delivery that materialized an assistant-initiated thread never mirrors.
503
+ * Under the `assistant-initiated-threads` flag, `conversation-pairing.ts`
504
+ * promotes a background `assistant.share` into a standard conversation
505
+ * stamped {@link ASSISTANT_INITIATED_SOURCE}, and that thread's row in the
506
+ * sidebar's assistant section is the share's one surface. Mirroring it here
507
+ * as well put the same share in the bell a second time, labeled by its
508
+ * source channel ("Heartbeat"). The check reads the paired conversation's
509
+ * `source` rather than re-deriving the promotion rule, so it holds however
510
+ * that rule narrows or widens. Guardian requests are checked first on
511
+ * purpose: the bell is their canonical home whatever conversation they pair
512
+ * with.
498
513
  */
499
514
  function resolveHomeFeedMirror(
500
515
  signal: NotificationSignal,
@@ -525,6 +540,15 @@ function resolveHomeFeedMirror(
525
540
  : fallbackConversationId;
526
541
  const sourceScheduleJobId = sourceRow?.scheduleJobId ?? undefined;
527
542
 
543
+ // Guardian requests always project into the feed: the "Needs
544
+ // attention" item is the request's canonical home, and the request
545
+ // blocks on the guardian whatever kind of conversation raised it.
546
+ if (isGuardianRequestSignalEvent(signal.sourceEventName)) {
547
+ return { mirror: true, sourceConversationId, sourceScheduleJobId };
548
+ }
549
+ if (isAssistantInitiatedThreadDelivery(fallbackConversationId)) {
550
+ return { mirror: false };
551
+ }
528
552
  if (
529
553
  signal.sourceChannel === "assistant_tool" ||
530
554
  signal.sourceEventName === "chat.assistant_reply"
@@ -534,18 +558,37 @@ function resolveHomeFeedMirror(
534
558
  if (signal.attentionHints.isAsyncBackground) {
535
559
  return { mirror: true, sourceConversationId, sourceScheduleJobId };
536
560
  }
537
- // Guardian requests always project into the feed: the "Needs
538
- // attention" item is the request's canonical home, and the request
539
- // blocks on the guardian whatever kind of conversation raised it.
540
- if (isGuardianRequestSignalEvent(signal.sourceEventName)) {
541
- return { mirror: true, sourceConversationId, sourceScheduleJobId };
542
- }
543
561
  if (isBackgroundConversationType(sourceRow?.conversationType)) {
544
562
  return { mirror: true, sourceConversationId, sourceScheduleJobId };
545
563
  }
546
564
  return { mirror: false };
547
565
  }
548
566
 
567
+ /**
568
+ * Whether the vellum delivery this signal produced landed in a thread the
569
+ * sidebar's assistant section already shows.
570
+ *
571
+ * Best-effort like the source lookup above: a missing id, a missing row, or
572
+ * a lookup failure all read as "not an assistant thread", so a storage
573
+ * hiccup degrades to the pre-existing behavior (a bell row) rather than
574
+ * dropping the notification everywhere.
575
+ */
576
+ function isAssistantInitiatedThreadDelivery(
577
+ deliveryConversationId: string | undefined,
578
+ ): boolean {
579
+ if (!deliveryConversationId) {
580
+ return false;
581
+ }
582
+ try {
583
+ return (
584
+ getConversation(deliveryConversationId)?.source ===
585
+ ASSISTANT_INITIATED_SOURCE
586
+ );
587
+ } catch {
588
+ return false;
589
+ }
590
+ }
591
+
549
592
  /**
550
593
  * Routing-intent enforcement can prune `selectedChannels` without also
551
594
  * pruning `renderedCopy`, so iterating `renderedCopy` directly risks
@@ -1,4 +1,5 @@
1
1
  import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
+ import { tableExists } from "./schema-introspection.js";
2
3
 
3
4
  /**
4
5
  * Add a required contact_id column to assistant_ingress_invites.
@@ -10,8 +11,16 @@ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
10
11
  * 1. Add the column as nullable.
11
12
  * 2. Delete legacy rows that have no contact binding (NULL).
12
13
  * 3. Rebuild the table to enforce NOT NULL (SQLite cannot ALTER COLUMN).
14
+ *
15
+ * The rebuild copies whichever session-id column is live:
16
+ * `source_conversation_id` (after migrateRenameCreatedBySessionIdColumns)
17
+ * or `created_by_session_id`.
13
18
  */
14
19
  export function migrateInviteContactId(database: DrizzleDb): void {
20
+ if (!tableExists(database, "assistant_ingress_invites")) {
21
+ return;
22
+ }
23
+
15
24
  const raw = getSqliteFrom(database);
16
25
 
17
26
  const cols = (
@@ -20,25 +29,28 @@ export function migrateInviteContactId(database: DrizzleDb): void {
20
29
  notnull: number;
21
30
  }>
22
31
  ).map((c) => ({ name: c.name, notnull: c.notnull }));
32
+ const colNames = new Set(cols.map((c) => c.name));
23
33
 
24
34
  const col = cols.find((c) => c.name === "contact_id");
25
35
 
26
- // Step 1: Add the column as nullable
27
36
  if (!col) {
28
37
  raw.exec(
29
38
  /*sql*/ `ALTER TABLE assistant_ingress_invites ADD COLUMN contact_id TEXT`,
30
39
  );
31
40
  }
32
41
 
33
- // Step 2: Delete legacy rows with no contact binding
34
42
  raw.exec(
35
43
  /*sql*/ `DELETE FROM assistant_ingress_invites WHERE contact_id IS NULL OR contact_id = ''`,
36
44
  );
37
45
 
38
- // Step 3: Rebuild the table with NOT NULL on contact_id
39
46
  if (col && col.notnull === 1) {
40
47
  return;
41
- } // already NOT NULL
48
+ }
49
+
50
+ const sessionCol = colNames.has("source_conversation_id")
51
+ ? "source_conversation_id"
52
+ : "created_by_session_id";
53
+ const sessionSelect = colNames.has(sessionCol) ? sessionCol : "NULL";
42
54
 
43
55
  raw.exec("PRAGMA foreign_keys = OFF");
44
56
  try {
@@ -49,7 +61,7 @@ export function migrateInviteContactId(database: DrizzleDb): void {
49
61
  id TEXT PRIMARY KEY,
50
62
  source_channel TEXT NOT NULL,
51
63
  token_hash TEXT NOT NULL,
52
- created_by_session_id TEXT,
64
+ ${sessionCol} TEXT,
53
65
  note TEXT,
54
66
  max_uses INTEGER NOT NULL DEFAULT 1 CHECK (max_uses > 0),
55
67
  use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0),
@@ -70,7 +82,7 @@ export function migrateInviteContactId(database: DrizzleDb): void {
70
82
  );
71
83
 
72
84
  INSERT INTO assistant_ingress_invites_new
73
- SELECT id, source_channel, token_hash, created_by_session_id, note,
85
+ SELECT id, source_channel, token_hash, ${sessionSelect}, note,
74
86
  max_uses, use_count, expires_at, status,
75
87
  redeemed_by_external_user_id, redeemed_by_external_chat_id,
76
88
  redeemed_at, expected_external_user_id, voice_code_hash,
@@ -0,0 +1,149 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+
6
+ import { getSqliteFrom } from "../../db-connection.js";
7
+ import * as schema from "../../schema.js";
8
+ import { migrateInviteContactId } from "../157-invite-contact-id.js";
9
+ import { tableExists, tableHasColumn } from "../schema-introspection.js";
10
+
11
+ function createTestDb() {
12
+ const sqlite = new Database(":memory:");
13
+ sqlite.exec("PRAGMA journal_mode=WAL");
14
+ return drizzle(sqlite, { schema });
15
+ }
16
+
17
+ function columnNotNull(raw: Database, name: string): boolean {
18
+ const cols = raw.query(`PRAGMA table_info(assistant_ingress_invites)`).all() as Array<{
19
+ name: string;
20
+ notnull: number;
21
+ }>;
22
+ return cols.some((c) => c.name === name && c.notnull === 1);
23
+ }
24
+
25
+ describe("migrateInviteContactId", () => {
26
+ test("rebuilds a legacy created_by_session_id table with NOT NULL contact_id", () => {
27
+ const db = createTestDb();
28
+ const raw = getSqliteFrom(db);
29
+ raw.exec(/*sql*/ `
30
+ CREATE TABLE assistant_ingress_invites (
31
+ id TEXT PRIMARY KEY,
32
+ source_channel TEXT NOT NULL,
33
+ token_hash TEXT NOT NULL,
34
+ created_by_session_id TEXT,
35
+ note TEXT,
36
+ max_uses INTEGER NOT NULL DEFAULT 1,
37
+ use_count INTEGER NOT NULL DEFAULT 0,
38
+ expires_at INTEGER NOT NULL,
39
+ status TEXT NOT NULL DEFAULT 'active',
40
+ redeemed_by_external_user_id TEXT,
41
+ redeemed_by_external_chat_id TEXT,
42
+ redeemed_at INTEGER,
43
+ expected_external_user_id TEXT,
44
+ voice_code_hash TEXT,
45
+ voice_code_digits INTEGER,
46
+ invite_code_hash TEXT,
47
+ friend_name TEXT,
48
+ guardian_name TEXT,
49
+ created_at INTEGER NOT NULL,
50
+ updated_at INTEGER NOT NULL
51
+ )
52
+ `);
53
+ raw.run(
54
+ `INSERT INTO assistant_ingress_invites
55
+ (id, source_channel, token_hash, created_by_session_id, max_uses, use_count,
56
+ expires_at, status, created_at, updated_at)
57
+ VALUES ('inv-keep', 'slack', 'hash-keep', 'conv-1', 1, 0, 9, 'active', 1, 1)`,
58
+ );
59
+ raw.exec(
60
+ `ALTER TABLE assistant_ingress_invites ADD COLUMN contact_id TEXT`,
61
+ );
62
+ raw.run(
63
+ `UPDATE assistant_ingress_invites SET contact_id = 'contact-keep' WHERE id = 'inv-keep'`,
64
+ );
65
+ raw.run(
66
+ `INSERT INTO assistant_ingress_invites
67
+ (id, source_channel, token_hash, max_uses, use_count, expires_at, status,
68
+ created_at, updated_at)
69
+ VALUES ('inv-drop', 'slack', 'hash-drop', 1, 0, 9, 'active', 1, 1)`,
70
+ );
71
+
72
+ migrateInviteContactId(db);
73
+
74
+ expect(tableHasColumn(db, "assistant_ingress_invites", "contact_id")).toBe(
75
+ true,
76
+ );
77
+ expect(columnNotNull(raw, "contact_id")).toBe(true);
78
+ expect(
79
+ tableHasColumn(db, "assistant_ingress_invites", "created_by_session_id"),
80
+ ).toBe(true);
81
+ const rows = raw
82
+ .query(`SELECT id, contact_id FROM assistant_ingress_invites ORDER BY id`)
83
+ .all() as Array<{ id: string; contact_id: string }>;
84
+ expect(rows).toEqual([{ id: "inv-keep", contact_id: "contact-keep" }]);
85
+ });
86
+
87
+ test("rebuilds after created_by_session_id was renamed to source_conversation_id", () => {
88
+ const db = createTestDb();
89
+ const raw = getSqliteFrom(db);
90
+ raw.exec(/*sql*/ `
91
+ CREATE TABLE assistant_ingress_invites (
92
+ id TEXT PRIMARY KEY,
93
+ source_channel TEXT NOT NULL,
94
+ token_hash TEXT NOT NULL,
95
+ source_conversation_id TEXT,
96
+ note TEXT,
97
+ max_uses INTEGER NOT NULL DEFAULT 1,
98
+ use_count INTEGER NOT NULL DEFAULT 0,
99
+ expires_at INTEGER NOT NULL,
100
+ status TEXT NOT NULL DEFAULT 'active',
101
+ redeemed_by_external_user_id TEXT,
102
+ redeemed_by_external_chat_id TEXT,
103
+ redeemed_at INTEGER,
104
+ expected_external_user_id TEXT,
105
+ voice_code_hash TEXT,
106
+ voice_code_digits INTEGER,
107
+ invite_code_hash TEXT,
108
+ friend_name TEXT,
109
+ guardian_name TEXT,
110
+ created_at INTEGER NOT NULL,
111
+ updated_at INTEGER NOT NULL
112
+ )
113
+ `);
114
+ raw.exec(
115
+ `ALTER TABLE assistant_ingress_invites ADD COLUMN contact_id TEXT`,
116
+ );
117
+ raw.run(
118
+ `INSERT INTO assistant_ingress_invites
119
+ (id, source_channel, token_hash, source_conversation_id, contact_id,
120
+ max_uses, use_count, expires_at, status, created_at, updated_at)
121
+ VALUES ('inv-1', 'slack', 'hash-1', 'conv-9', 'contact-1', 1, 0, 9, 'active', 1, 1)`,
122
+ );
123
+
124
+ migrateInviteContactId(db);
125
+
126
+ expect(columnNotNull(raw, "contact_id")).toBe(true);
127
+ expect(
128
+ tableHasColumn(db, "assistant_ingress_invites", "source_conversation_id"),
129
+ ).toBe(true);
130
+ expect(
131
+ tableHasColumn(db, "assistant_ingress_invites", "created_by_session_id"),
132
+ ).toBe(false);
133
+ const row = raw
134
+ .query(
135
+ `SELECT source_conversation_id, contact_id FROM assistant_ingress_invites WHERE id = 'inv-1'`,
136
+ )
137
+ .get() as { source_conversation_id: string; contact_id: string };
138
+ expect(row).toEqual({
139
+ source_conversation_id: "conv-9",
140
+ contact_id: "contact-1",
141
+ });
142
+ });
143
+
144
+ test("is a no-op when the table does not exist", () => {
145
+ const db = createTestDb();
146
+ migrateInviteContactId(db);
147
+ expect(tableExists(db, "assistant_ingress_invites")).toBe(false);
148
+ });
149
+ });