@vellumai/assistant 0.11.8-dev.202609030544.f41a019 → 0.11.8-dev.202609031226.a37d2ae

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.yaml CHANGED
@@ -9978,7 +9978,11 @@ paths:
9978
9978
  unassigned conversations, Chats) with total and unread counts and no conversation rows. Lets a client know which
9979
9979
  sections exist, and what their badges say, without fetching any conversation list. Totals follow the standard
9980
9980
  listing visibility; unread applies the same rules as GET /v1/conversations/unread-count scoped to the section.
9981
- Chats is always present, even at zero: it is the leftover bucket and renders regardless."
9981
+ Chats is always present, even at zero: it is the leftover bucket and renders regardless. Under the
9982
+ `assistant-initiated-threads` flag an `assistant` section is also present at every count — the threads the
9983
+ assistant started on its own, selectable as a list via `groupId=system:assistant` — and its rows are withheld
9984
+ from Chats so the buckets stay disjoint. With the flag off no `assistant` row is emitted and those conversations
9985
+ are counted in Chats as before."
9982
9986
  tags:
9983
9987
  - conversations
9984
9988
  responses:
@@ -10007,6 +10011,20 @@ paths:
10007
10011
  - total
10008
10012
  - unread
10009
10013
  additionalProperties: false
10014
+ - type: object
10015
+ properties:
10016
+ kind:
10017
+ type: string
10018
+ const: assistant
10019
+ total:
10020
+ type: number
10021
+ unread:
10022
+ type: number
10023
+ required:
10024
+ - kind
10025
+ - total
10026
+ - unread
10027
+ additionalProperties: false
10010
10028
  - type: object
10011
10029
  properties:
10012
10030
  kind:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.8-dev.202609030544.f41a019",
3
+ "version": "0.11.8-dev.202609031226.a37d2ae",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,9 +19,17 @@ let addMessageShouldThrow = false;
19
19
  /** Simulated existing conversations for getConversation mock. */
20
20
  let mockExistingConversations: Record<
21
21
  string,
22
- { id: string; source: string; title: string | null }
22
+ {
23
+ id: string;
24
+ source: string;
25
+ title: string | null;
26
+ conversationType?: string;
27
+ }
23
28
  > = {};
24
29
 
30
+ /** Mutable arm for the assistant-initiated-threads gate mock. */
31
+ let assistantInitiatedThreadsEnabled = false;
32
+
25
33
  const createConversationMock = mock((_opts?: unknown) => {
26
34
  if (createConversationShouldThrow) {
27
35
  throw new Error("DB write failed");
@@ -102,6 +110,12 @@ mock.module("../persistence/external-conversation-store.js", () => ({
102
110
  upsertOutboundBinding: upsertOutboundBindingMock,
103
111
  }));
104
112
 
113
+ /* The registry resolves through workspace override files; a mutable arm keeps
114
+ these tests off the filesystem, mirroring the section-split suite. */
115
+ mock.module("../config/assistant-initiated-threads-gate.js", () => ({
116
+ isAssistantInitiatedThreadsEnabled: () => assistantInitiatedThreadsEnabled,
117
+ }));
118
+
105
119
  import { pairDeliveryWithConversation } from "../notifications/conversation-pairing.js";
106
120
  import type { NotificationSignal } from "../notifications/signal.js";
107
121
  import type {
@@ -162,6 +176,7 @@ describe("pairDeliveryWithConversation", () => {
162
176
  addMessageShouldThrow = false;
163
177
  mockExistingConversations = {};
164
178
  mockBindings = {};
179
+ assistantInitiatedThreadsEnabled = false;
165
180
  });
166
181
 
167
182
  // ── start_new_conversation (vellum) ─────────────────────────────────
@@ -1356,4 +1371,129 @@ describe("pairDeliveryWithConversation", () => {
1356
1371
  expect(result.conversationId).toBe("conv-001");
1357
1372
  expect(result.messageId).toBe("msg-001");
1358
1373
  });
1374
+ // ── assistant.share → assistant-initiated thread (flag-gated) ────────
1375
+
1376
+ describe("assistant.share promotion under assistant-initiated-threads", () => {
1377
+ const share = (overrides?: Partial<NotificationSignal>) =>
1378
+ makeSignal({
1379
+ requiresConversation: undefined,
1380
+ sourceEventName: "assistant.share",
1381
+ sourceContextId: "conv-heartbeat",
1382
+ ...overrides,
1383
+ });
1384
+
1385
+ test("flag on: a share from a background run materializes a section thread", async () => {
1386
+ assistantInitiatedThreadsEnabled = true;
1387
+ mockExistingConversations["conv-heartbeat"] = {
1388
+ id: "conv-heartbeat",
1389
+ source: "heartbeat",
1390
+ title: null,
1391
+ conversationType: "background",
1392
+ };
1393
+
1394
+ const result = await pairDeliveryWithConversation(
1395
+ share(),
1396
+ "vellum" as NotificationChannel,
1397
+ makeCopy({ conversationTitle: "Noticed something" }),
1398
+ );
1399
+
1400
+ expect(result.createdNewConversation).toBe(true);
1401
+ const callArgs = createConversationMock.mock.calls[0]![0] as Record<
1402
+ string,
1403
+ unknown
1404
+ >;
1405
+ expect(callArgs.source).toBe("assistant_initiated");
1406
+ expect(callArgs.conversationType).toBe("standard");
1407
+ expect(callArgs.title).toBe("Noticed something");
1408
+ });
1409
+
1410
+ test("flag on: an unresolvable source context still materializes", async () => {
1411
+ assistantInitiatedThreadsEnabled = true;
1412
+
1413
+ const result = await pairDeliveryWithConversation(
1414
+ share({ sourceContextId: "cli-123" }),
1415
+ "vellum" as NotificationChannel,
1416
+ makeCopy(),
1417
+ );
1418
+
1419
+ expect(result.createdNewConversation).toBe(true);
1420
+ const callArgs = createConversationMock.mock.calls[0]![0] as Record<
1421
+ string,
1422
+ unknown
1423
+ >;
1424
+ expect(callArgs.source).toBe("assistant_initiated");
1425
+ });
1426
+
1427
+ test("flag on: a share from a user-facing thread keeps the passive append", async () => {
1428
+ assistantInitiatedThreadsEnabled = true;
1429
+ mockExistingConversations["conv-user"] = {
1430
+ id: "conv-user",
1431
+ source: "user",
1432
+ title: "Ongoing chat",
1433
+ conversationType: "standard",
1434
+ };
1435
+
1436
+ const result = await pairDeliveryWithConversation(
1437
+ share({ sourceContextId: "conv-user" }),
1438
+ "vellum" as NotificationChannel,
1439
+ makeCopy(),
1440
+ );
1441
+
1442
+ expect(result.createdNewConversation).toBe(false);
1443
+ expect(result.conversationId).toBe("conv-user");
1444
+ expect(createConversationMock).not.toHaveBeenCalled();
1445
+ });
1446
+
1447
+ test("flag off: shares keep the passive append even from background runs", async () => {
1448
+ mockExistingConversations["conv-heartbeat"] = {
1449
+ id: "conv-heartbeat",
1450
+ source: "heartbeat",
1451
+ title: null,
1452
+ conversationType: "background",
1453
+ };
1454
+
1455
+ const result = await pairDeliveryWithConversation(
1456
+ share(),
1457
+ "vellum" as NotificationChannel,
1458
+ makeCopy(),
1459
+ );
1460
+
1461
+ expect(result.createdNewConversation).toBe(false);
1462
+ expect(result.conversationId).toBe("conv-heartbeat");
1463
+ expect(createConversationMock).not.toHaveBeenCalled();
1464
+ });
1465
+
1466
+ test("flag on: an explicit conversationMetadata.source wins over promotion", async () => {
1467
+ assistantInitiatedThreadsEnabled = true;
1468
+
1469
+ const result = await pairDeliveryWithConversation(
1470
+ share({ conversationMetadata: { source: "schedule" } }),
1471
+ "vellum" as NotificationChannel,
1472
+ makeCopy(),
1473
+ );
1474
+
1475
+ // The producer declared its own filing, so the passive rule holds.
1476
+ expect(result.createdNewConversation).toBe(false);
1477
+ expect(createConversationMock).not.toHaveBeenCalled();
1478
+ });
1479
+
1480
+ test("flag on: non-share passive events keep the passive append", async () => {
1481
+ assistantInitiatedThreadsEnabled = true;
1482
+ mockExistingConversations["conv-heartbeat"] = {
1483
+ id: "conv-heartbeat",
1484
+ source: "heartbeat",
1485
+ title: null,
1486
+ conversationType: "background",
1487
+ };
1488
+
1489
+ const result = await pairDeliveryWithConversation(
1490
+ share({ sourceEventName: "scheduler.reminder_fired" }),
1491
+ "vellum" as NotificationChannel,
1492
+ makeCopy(),
1493
+ );
1494
+
1495
+ expect(result.createdNewConversation).toBe(false);
1496
+ expect(createConversationMock).not.toHaveBeenCalled();
1497
+ });
1498
+ });
1359
1499
  });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The gate reads a string-valued flag that does not always arrive as a
3
+ * string.
4
+ *
5
+ * The registry and the gateway's persisted store carry the declared `"on"` /
6
+ * `"off"` strings. The gateway's env-override path does not: its parser
7
+ * (`feature-flag-env-overrides.ts`) treats `on` as a truthy *word* and
8
+ * coerces it to boolean `true`, then applies env overrides last — so
9
+ * `VELLUM_FLAG_ASSISTANT_INITIATED_THREADS=on` reaches the daemon as `true`.
10
+ *
11
+ * A strict `=== "on"` therefore read the documented env kill-switch as OFF,
12
+ * and did it silently: the section just never rendered. That shipped in the
13
+ * first cut of this gate and cost an afternoon of QA, which is why both
14
+ * shapes are pinned here rather than only the one the registry emits.
15
+ */
16
+
17
+ import { afterEach, describe, expect, mock, test } from "bun:test";
18
+
19
+ let flagValue: boolean | string = "off";
20
+
21
+ mock.module("../assistant-feature-flags.js", () => ({
22
+ getAssistantFeatureFlagValue: () => flagValue,
23
+ }));
24
+
25
+ const { isAssistantInitiatedThreadsEnabled } = await import(
26
+ "../assistant-initiated-threads-gate.js"
27
+ );
28
+
29
+ afterEach(() => {
30
+ flagValue = "off";
31
+ });
32
+
33
+ describe("isAssistantInitiatedThreadsEnabled", () => {
34
+ test('enabled by the declared string "on" (registry / persisted store)', () => {
35
+ flagValue = "on";
36
+ expect(isAssistantInitiatedThreadsEnabled()).toBe(true);
37
+ });
38
+
39
+ test("enabled by boolean true (the gateway's env-override coercion)", () => {
40
+ // The regression. `VELLUM_FLAG_ASSISTANT_INITIATED_THREADS=on` arrives
41
+ // here as `true`, never as "on".
42
+ flagValue = true;
43
+ expect(isAssistantInitiatedThreadsEnabled()).toBe(true);
44
+ });
45
+
46
+ test('disabled by the declared string "off"', () => {
47
+ flagValue = "off";
48
+ expect(isAssistantInitiatedThreadsEnabled()).toBe(false);
49
+ });
50
+
51
+ test("disabled by boolean false (env `=off` coercion)", () => {
52
+ flagValue = false;
53
+ expect(isAssistantInitiatedThreadsEnabled()).toBe(false);
54
+ });
55
+
56
+ test("fails closed on an unrecognized arm", () => {
57
+ // A future A/B value this gate has no opinion about must not read as on.
58
+ flagValue = "variant-b";
59
+ expect(isAssistantInitiatedThreadsEnabled()).toBe(false);
60
+ });
61
+ });
@@ -0,0 +1,50 @@
1
+ import { getAssistantFeatureFlagValue } from "./assistant-feature-flags.js";
2
+ import type { AssistantConfig } from "./schema.js";
3
+
4
+ const ASSISTANT_INITIATED_THREADS_FLAG_KEY =
5
+ "assistant-initiated-threads" as const;
6
+
7
+ /**
8
+ * Whether the conversations the assistant started on its own are split into
9
+ * their own sidebar section instead of sitting in Chats.
10
+ *
11
+ * Read through {@link getAssistantFeatureFlagValue} rather than
12
+ * `isAssistantFeatureFlagEnabled`, because the flag is string-valued: that
13
+ * helper coerces with `!!`, and `!!"off"` is `true`, so the boolean form would
14
+ * report the off arm as on.
15
+ *
16
+ * A read-side gate only. The rows exist either way — every notification
17
+ * delivery has always materialized one (`notifications/conversation-pairing.ts`)
18
+ * — so turning the flag off puts them back in Chats and takes the `assistant`
19
+ * row out of the section index, with nothing to migrate in either direction.
20
+ *
21
+ * This is the *only* gate on the feature, which is why the flag is
22
+ * assistant-scope rather than `both`. Clients render the section on the
23
+ * presence of the index row, because that row is what says the daemon has
24
+ * withheld those conversations from Chats. A second, client-side gate could
25
+ * only ever hide a section whose rows have already been withheld — losing
26
+ * those threads rather than reverting the feature — so there deliberately
27
+ * isn't one.
28
+ */
29
+ export function isAssistantInitiatedThreadsEnabled(
30
+ config?: AssistantConfig,
31
+ ): boolean {
32
+ const value = getAssistantFeatureFlagValue(
33
+ ASSISTANT_INITIATED_THREADS_FLAG_KEY,
34
+ config,
35
+ );
36
+ /* Both shapes are the same answer, and the on arm arrives as either one
37
+ depending on which layer resolved it.
38
+
39
+ The registry and the gateway's persisted store carry the declared string
40
+ (`"on"`). The gateway's env-override path does not: its parser treats
41
+ `on` as a truthy word (`TRUTHY = {"true","1","yes","on"}` in
42
+ `feature-flag-env-overrides.ts`) and coerces it to boolean `true`, then
43
+ applies env last — so `VELLUM_FLAG_ASSISTANT_INITIATED_THREADS=on`
44
+ reaches the daemon as `true`, not `"on"`.
45
+
46
+ A strict `=== "on"` therefore reads the env kill-switch as OFF, which is
47
+ the exact opposite of what an operator setting it intends, and it fails
48
+ silently: the section simply does not render. Accept both. */
49
+ return value === true || value === "on";
50
+ }
@@ -36,6 +36,15 @@
36
36
  "defaultEnabled": "off",
37
37
  "values": ["off", "on"]
38
38
  },
39
+ {
40
+ "id": "assistant-initiated-threads",
41
+ "scope": "assistant",
42
+ "key": "assistant-initiated-threads",
43
+ "label": "Assistant-Initiated Threads",
44
+ "description": "Gates the sidebar section collecting the conversations the assistant started on its own: rows stamped `source='assistant_initiated'` at creation by producers that opt a thread in (heartbeat realizations worth the user's time; nothing stamps it yet, so the section starts empty and holds only new threads by construction). Transactional notification trails keep `source='notification'` and stay in Chats and the bell. When on, the daemon emits an `assistant` row from the section index and withholds member conversations from the Chats bucket; when off, they stay in Chats and no `assistant` row is emitted, which is the shipped behavior. Deliberately daemon-scope only: the index row is self-describing, so clients render the section on its presence rather than on a flag of their own. A second client-side gate could only hide a section whose rows the daemon has already withheld from Chats, which would lose those threads instead of reverting the feature. String-valued so future A/B arms can be added as new values.",
45
+ "defaultEnabled": "off",
46
+ "values": ["off", "on"]
47
+ },
39
48
  {
40
49
  "id": "experiment-activation-flow-2026-06-03",
41
50
  "scope": "both",
@@ -23,11 +23,13 @@
23
23
  import type { ConversationStrategy } from "../channels/config.js";
24
24
  import { getConversationStrategy } from "../channels/config.js";
25
25
  import type { ChannelId } from "../channels/types.js";
26
+ import { isAssistantInitiatedThreadsEnabled } from "../config/assistant-initiated-threads-gate.js";
26
27
  import {
27
28
  addMessage,
28
29
  createConversation,
29
30
  getConversation,
30
31
  } from "../persistence/conversation-crud.js";
32
+ import { ASSISTANT_INITIATED_SOURCE } from "../persistence/conversation-types.js";
31
33
  import {
32
34
  getBindingByChannelChat,
33
35
  upsertOutboundBinding,
@@ -106,13 +108,77 @@ export interface PairingOptions {
106
108
  * Errors are caught and logged — this function never throws so the
107
109
  * notification pipeline is not disrupted by pairing failures.
108
110
  */
109
- export async function pairDeliveryWithConversation(
111
+ /**
112
+ * The event the assistant emits when it has a thought worth the user's time
113
+ * ("assistant.share" - the notifications skill's default), as opposed to a
114
+ * transactional request or a system alert.
115
+ */
116
+ const ASSISTANT_SHARE_EVENT = "assistant.share";
117
+
118
+ /**
119
+ * Promote a background share into an assistant-initiated thread, under the
120
+ * `assistant-initiated-threads` flag.
121
+ *
122
+ * The heartbeat's "have a thought, share it" path emits an assistant.share
123
+ * signal from its own background conversation, and the passive-vellum rule
124
+ * below would append the body there - a row the sidebar never shows. When the
125
+ * section exists, that share is exactly what it is for, so the signal is
126
+ * rewritten to materialize a fresh standard conversation stamped
127
+ * {@link ASSISTANT_INITIATED_SOURCE}, which is the section's membership mark.
128
+ *
129
+ * Deliberately narrow, in every direction it can be:
130
+ * - vellum channel only: other channels deliver the share as a native
131
+ * message and need no in-app thread;
132
+ * - assistant.share only: transactional events keep their own pairing
133
+ * rules, and their threads stay out of the section by source;
134
+ * - only when the producing conversation is a background/scheduled run (or
135
+ * nothing resolvable): a share emitted from inside a user-facing thread
136
+ * keeps the append, since the user is already looking at that thread;
137
+ * - never over an explicit `conversationMetadata.source` or an existing
138
+ * `requiresConversation`: a producer that declared its own filing wins.
139
+ *
140
+ * Flag off, the signal passes through untouched and shares keep the passive
141
+ * append: nothing changes for anyone outside the rollout.
142
+ */
143
+ function withAssistantInitiatedThread(
110
144
  signal: NotificationSignal,
111
145
  channel: NotificationChannel,
146
+ ): NotificationSignal {
147
+ if (
148
+ channel !== "vellum" ||
149
+ signal.sourceEventName !== ASSISTANT_SHARE_EVENT ||
150
+ signal.requiresConversation === true ||
151
+ signal.conversationMetadata?.source !== undefined ||
152
+ !isAssistantInitiatedThreadsEnabled()
153
+ ) {
154
+ return signal;
155
+ }
156
+ const producing = getConversation(signal.sourceContextId);
157
+ if (
158
+ producing &&
159
+ producing.conversationType !== "background" &&
160
+ producing.conversationType !== "scheduled"
161
+ ) {
162
+ return signal;
163
+ }
164
+ return {
165
+ ...signal,
166
+ requiresConversation: true,
167
+ conversationMetadata: {
168
+ ...signal.conversationMetadata,
169
+ source: ASSISTANT_INITIATED_SOURCE,
170
+ },
171
+ };
172
+ }
173
+
174
+ export async function pairDeliveryWithConversation(
175
+ rawSignal: NotificationSignal,
176
+ channel: NotificationChannel,
112
177
  copy: RenderedChannelCopy,
113
178
  options?: PairingOptions,
114
179
  ): Promise<PairingResult> {
115
180
  try {
181
+ const signal = withAssistantInitiatedThread(rawSignal, channel);
116
182
  const strategy = getConversationStrategy(channel as ChannelId);
117
183
 
118
184
  if (strategy === "not_deliverable" || strategy === "push_only") {
@@ -490,7 +556,7 @@ export async function pairDeliveryWithConversation(
490
556
  };
491
557
  } catch (err) {
492
558
  log.error(
493
- { err, signalId: signal.signalId, channel },
559
+ { err, signalId: rawSignal.signalId, channel },
494
560
  "Failed to pair notification delivery with conversation — continuing without pairing",
495
561
  );
496
562
  const fallbackStrategy = (() => {
@@ -25,6 +25,8 @@ import { ensureDisplayOrderMigration } from "./conversation-display-order-migrat
25
25
  import { ensureGroupMigration } from "./conversation-group-migration.js";
26
26
  import { searchMessageIdsLexical } from "./conversation-search-lexical.js";
27
27
  import {
28
+ ASSISTANT_INITIATED_GROUP_ID,
29
+ ASSISTANT_INITIATED_SOURCE,
28
30
  type ConversationType,
29
31
  NATIVE_ORIGIN_CHANNEL,
30
32
  PINNED_GROUP_ID,
@@ -213,6 +215,58 @@ function notBackgroundVisibilitySql(alias = "conversations"): string {
213
215
  );
214
216
  }
215
217
 
218
+ /**
219
+ * Raw SQL predicate for "a thread the assistant started on its own": the rows
220
+ * stamped {@link ASSISTANT_INITIATED_SOURCE} at creation by a producer that
221
+ * opted the thread into the section.
222
+ *
223
+ * Membership rides `source` rather than `group_id` because those rows are
224
+ * filed nowhere — see {@link ASSISTANT_INITIATED_GROUP_ID} for why the section
225
+ * is still named as a group id. It is a dedicated source rather than the
226
+ * notification pipeline's `'notification'`, whose rows are the transactional
227
+ * request trails (guardian approvals, confirmation / access / question /
228
+ * tool-grant requests, channel deliveries) that belong to the bell and to
229
+ * Chats - see {@link ASSISTANT_INITIATED_SOURCE} for the full rationale.
230
+ */
231
+ function assistantInitiatedSql(alias = "conversations"): string {
232
+ return `(${alias}.source = '${ASSISTANT_INITIATED_SOURCE}' AND ${ungroupedSql(alias)})`;
233
+ }
234
+
235
+ /**
236
+ * The complement of {@link assistantInitiatedSql}, spelled out rather than
237
+ * wrapped in `NOT`.
238
+ *
239
+ * `source` is nullable and the overwhelming majority of rows carry NULL, so
240
+ * `NOT (source = '...')` evaluates to NULL for them under SQL's three-valued
241
+ * logic and the WHERE clause drops them. Negating the column naively would
242
+ * therefore not withhold one section from Chats - it would empty Chats.
243
+ */
244
+ function notAssistantInitiatedSql(alias = "conversations"): string {
245
+ return `(${alias}.source IS NULL OR ${alias}.source != '${ASSISTANT_INITIATED_SOURCE}' OR NOT ${ungroupedSql(alias)})`;
246
+ }
247
+
248
+ /**
249
+ * Raw SQL predicate for {@link UNGROUPED_GROUP_ID}: every row the sidebar's
250
+ * flat list shows — not pinned and not filed in a custom group.
251
+ *
252
+ * It deliberately admits the system buckets, because a surfaced background or
253
+ * scheduled conversation keeps its `system:background` / `system:scheduled`
254
+ * group id (surfacing writes only `surfaced_at`) while the standard listing
255
+ * renders it in Recents. Whether such a row is actually visible stays with
256
+ * {@link conversationTypeClause}, which admits the surfaced ones and excludes
257
+ * the rest; matching on group id alone here would drop them.
258
+ *
259
+ * Named because two sections are carved out of this same set — Chats and the
260
+ * assistant-initiated threads — and they have to agree on what it contains or
261
+ * a row lands in both or neither.
262
+ */
263
+ function ungroupedSql(alias = "conversations"): string {
264
+ return (
265
+ `(${alias}.group_id IS NULL` +
266
+ ` OR (${alias}.group_id LIKE 'system:%' AND ${alias}.group_id != '${PINNED_GROUP_ID}'))`
267
+ );
268
+ }
269
+
216
270
  /**
217
271
  * SQL predicate selecting which bucket {@link listConversations} and
218
272
  * {@link countConversations} return, keyed by the canonical
@@ -293,6 +347,21 @@ export interface ConversationListFilter {
293
347
  * row instead of paging past runs it would skip.
294
348
  */
295
349
  foregroundOnly?: true;
350
+ /**
351
+ * Withhold the threads the assistant started on its own
352
+ * ({@link assistantInitiatedSql}) from the result.
353
+ *
354
+ * The exact complement of `groupId: ASSISTANT_INITIATED_GROUP_ID`, and the
355
+ * other half of splitting that section out of Chats: without it those rows
356
+ * would be counted and listed twice, once in their own section and once in
357
+ * the leftover bucket that never excluded them. Being the exact complement
358
+ * matters as much as the exclusion — a thread the user pinned or filed into
359
+ * a custom group is not in the assistant section, so this must not withhold
360
+ * it from the section that does hold it. Only `true` narrows; omit to keep
361
+ * every row, which is what every caller does while the
362
+ * `assistant-initiated-threads` flag is off.
363
+ */
364
+ excludeAssistantInitiated?: true;
296
365
  }
297
366
 
298
367
  export interface ConversationListQuery extends ConversationListFilter {
@@ -313,16 +382,15 @@ export interface ConversationListQuery extends ConversationListFilter {
313
382
  * declared in `schema/conversations.ts`.
314
383
  */
315
384
  function groupIdClause(groupId: string) {
385
+ if (groupId === ASSISTANT_INITIATED_GROUP_ID) {
386
+ // Not a `group_id` match: `source` decides membership. See
387
+ // ASSISTANT_INITIATED_GROUP_ID. Still narrowed to the ungrouped set,
388
+ // because filing one of these threads into a custom group — or pinning it
389
+ // — must move it there rather than leave it in two sections at once.
390
+ return sql.raw(assistantInitiatedSql());
391
+ }
316
392
  if (groupId === UNGROUPED_GROUP_ID) {
317
- // "Ungrouped" means every row the sidebar's flat list shows: not pinned
318
- // and not filed in a custom group. It deliberately admits the system
319
- // buckets, because a surfaced background or scheduled conversation keeps
320
- // its `system:background` / `system:scheduled` group id (surfacing writes
321
- // only `surfaced_at`) while the standard listing renders it in Recents.
322
- // Whether such a row is actually visible stays with
323
- // `conversationTypeClause`, which admits the surfaced ones and excludes
324
- // the rest; matching on group id alone here would drop them.
325
- return sql`(group_id IS NULL OR (group_id LIKE 'system:%' AND group_id != ${PINNED_GROUP_ID}))`;
393
+ return sql.raw(ungroupedSql());
326
394
  }
327
395
  // `group_id` is single-valued and authoritative, so a row belongs to
328
396
  // exactly one section by construction. `is_pinned` is a derived duplicate
@@ -376,6 +444,7 @@ function conversationListWhere(filter: ConversationListFilter) {
376
444
  groupId,
377
445
  needsAttention,
378
446
  foregroundOnly,
447
+ excludeAssistantInitiated,
379
448
  } = filter;
380
449
  return and(
381
450
  conversationTypeClause(conversationType),
@@ -384,6 +453,7 @@ function conversationListWhere(filter: ConversationListFilter) {
384
453
  groupId ? groupIdClause(groupId) : undefined,
385
454
  ...(needsAttention ? unseenAttentionStateConditions() : []),
386
455
  foregroundOnly ? sql.raw(notBackgroundVisibilitySql()) : undefined,
456
+ excludeAssistantInitiated ? sql.raw(notAssistantInitiatedSql()) : undefined,
387
457
  );
388
458
  }
389
459
 
@@ -699,11 +769,34 @@ export interface ConversationSectionCount {
699
769
  export interface ConversationSectionCounts {
700
770
  groups: Array<{ groupId: string } & ConversationSectionCount>;
701
771
  channels: Array<{ channel: string } & ConversationSectionCount>;
772
+ /**
773
+ * The assistant-initiated section, present only when the caller asked for
774
+ * the split ({@link CountConversationSectionsOptions.splitAssistantInitiated}).
775
+ * Absent — not zero — when it did not, so a caller that never asked cannot
776
+ * mistake "not split out" for "split out and empty".
777
+ *
778
+ * When present, these rows are withheld from `channels`, keeping the two
779
+ * axes disjoint the way `groups` and `channels` already are.
780
+ */
781
+ assistantInitiated?: ConversationSectionCount;
782
+ }
783
+
784
+ export interface CountConversationSectionsOptions {
785
+ /**
786
+ * Count the threads the assistant started on its own as their own section
787
+ * and withhold them from the channel buckets. Off by default: the caller
788
+ * that knows about the `assistant-initiated-threads` flag turns it on, and
789
+ * every other caller keeps today's shape.
790
+ */
791
+ splitAssistantInitiated?: boolean;
702
792
  }
703
793
 
704
- export function countConversationSections(): ConversationSectionCounts {
794
+ export function countConversationSections(
795
+ options: CountConversationSectionsOptions = {},
796
+ ): ConversationSectionCounts {
705
797
  ensureGroupMigration();
706
798
  const db = getDb();
799
+ const { splitAssistantInitiated = false } = options;
707
800
 
708
801
  /* `countUnreadConversations` gets the same effect with an INNER JOIN and
709
802
  the unseen conditions in its WHERE; here every row must be counted and
@@ -747,12 +840,48 @@ export function countConversationSections(): ConversationSectionCounts {
747
840
  conversationTypeClause("standard"),
748
841
  isNull(conversations.archivedAt),
749
842
  groupIdClause(UNGROUPED_GROUP_ID),
843
+ /* The split's other half. These rows are ungrouped and natively
844
+ attributed, so without this they stay in the Chats bucket and get
845
+ counted in both sections at once. */
846
+ splitAssistantInitiated
847
+ ? sql.raw(notAssistantInitiatedSql())
848
+ : undefined,
750
849
  ),
751
850
  )
752
851
  .groupBy(effectiveChannel)
753
852
  .all();
754
853
 
755
- return { groups, channels };
854
+ if (!splitAssistantInitiated) {
855
+ return { groups, channels };
856
+ }
857
+
858
+ /* Composed from the same predicates as the buckets above rather than
859
+ restated, so this section's badge cannot drift from theirs: same standard
860
+ listing visibility, same active-rows-only rule, same unread CASE. */
861
+ const [assistantInitiated] = db
862
+ .select({ total: count(), unread })
863
+ .from(conversations)
864
+ .leftJoin(conversationAssistantAttentionState, attentionJoin)
865
+ .where(
866
+ and(
867
+ conversationTypeClause("standard"),
868
+ isNull(conversations.archivedAt),
869
+ groupIdClause(ASSISTANT_INITIATED_GROUP_ID),
870
+ ),
871
+ )
872
+ .all();
873
+
874
+ return {
875
+ groups,
876
+ channels,
877
+ /* The aggregate is over no GROUP BY, so it always yields one row — but it
878
+ yields `unread: null` rather than 0 when no row matched, because SUM
879
+ over an empty set is NULL. */
880
+ assistantInitiated: {
881
+ total: assistantInitiated?.total ?? 0,
882
+ unread: assistantInitiated?.unread ?? 0,
883
+ },
884
+ };
756
885
  }
757
886
 
758
887
  /**
@@ -82,10 +82,7 @@ export function isBackgroundConversationType(
82
82
  * columns into one label.
83
83
  */
84
84
  export type ConversationKind =
85
- | "user"
86
- | "background"
87
- | "background_memory_consolidation"
88
- | "scheduled";
85
+ "user" | "background" | "background_memory_consolidation" | "scheduled";
89
86
 
90
87
  /**
91
88
  * Single classifier shared by the LLM-context routes and the notification
@@ -451,6 +448,41 @@ export function isReplyPushIneligibleUserMessage(
451
448
  export const UNGROUPED_GROUP_ID = "system:all";
452
449
  export const PINNED_GROUP_ID = "system:pinned";
453
450
 
451
+ /**
452
+ * The `conversations.source` that makes a thread a member of the
453
+ * assistant-initiated section ({@link ASSISTANT_INITIATED_GROUP_ID}).
454
+ *
455
+ * Opt-in and stamped at creation, by a producer declaring "this is a thread
456
+ * I started because it is worth the user's time" - a heartbeat realization,
457
+ * an observation the user did not ask for. Deliberately NOT the notification
458
+ * pipeline's `'notification'`: the conversations that stamp carries are the
459
+ * transactional flows (guardian call approvals, confirmation / access /
460
+ * question / tool-grant requests, channel delivery trails - the
461
+ * `requiresConversation` producers), which belong to the bell and to Chats,
462
+ * and matching on it would also sweep every historical row into the section
463
+ * the day the flag turns on. A dedicated source makes membership all-new by
464
+ * construction: nothing stamps it until a producer passes it through
465
+ * `conversationMetadata.source` on its notification signal (see
466
+ * `notifications/conversation-pairing.ts`), so the section holds exactly the
467
+ * threads written for it and nothing retroactively.
468
+ */
469
+ // FROZEN: persisted `conversations.source` value. Never rename it.
470
+ export const ASSISTANT_INITIATED_SOURCE = "assistant_initiated";
471
+
472
+ /**
473
+ * The section holding the conversations the assistant started on its own:
474
+ * the threads stamped {@link ASSISTANT_INITIATED_SOURCE} at creation.
475
+ *
476
+ * A pseudo-group in the same sense as {@link UNGROUPED_GROUP_ID}: no row
477
+ * carries it in `group_id`. Those conversations are filed nowhere (NULL
478
+ * `group_id`), so without the split they land in Chats; membership is
479
+ * decided by `source`, not by the column this id names. It is spelled as a
480
+ * group id anyway so a client selects the section through the `groupId`
481
+ * parameter every other section already uses, rather than through a filter
482
+ * only this one section knows about.
483
+ */
484
+ export const ASSISTANT_INITIATED_GROUP_ID = "system:assistant";
485
+
454
486
  /**
455
487
  * The `origin_channel` value for a conversation started in Vellum itself
456
488
  * rather than arriving from an external channel.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * The `assistant-initiated-threads` split: the threads the assistant started
3
+ * on its own (`source = 'assistant_initiated'`, opt-in at creation) become
4
+ * their own sidebar section instead of sitting in Chats.
5
+ *
6
+ * The flag is the whole point of these tests. It is a read-side gate over rows
7
+ * that exist either way, so the off arm must be byte-identical to the behavior
8
+ * that shipped before the section existed — that is what makes the feature
9
+ * reversible without a migration. Both arms are asserted for every claim.
10
+ *
11
+ * The gate module is mocked rather than the flag registry: the registry
12
+ * resolves through workspace override files, and a test that wrote those would
13
+ * be asserting the resolver's behavior rather than this route's.
14
+ */
15
+
16
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
17
+
18
+ // Avoid spinning up the real event hub for the pinned/groups branches.
19
+ mock.module("../../assistant-event-hub.js", () => ({
20
+ assistantEventHub: {
21
+ publish: async () => {},
22
+ subscribe: () => () => {},
23
+ },
24
+ broadcastMessage: () => {},
25
+ }));
26
+
27
+ let flagEnabled = false;
28
+ mock.module("../../../config/assistant-initiated-threads-gate.js", () => ({
29
+ isAssistantInitiatedThreadsEnabled: () => flagEnabled,
30
+ }));
31
+
32
+ import { projectAssistantMessage } from "../../../persistence/conversation-attention-store.js";
33
+ import { createConversation } from "../../../persistence/conversation-crud.js";
34
+ import { getDb } from "../../../persistence/db-connection.js";
35
+ import { initializeDb } from "../../../persistence/db-init.js";
36
+ import { createGroup } from "../../../persistence/group-crud.js";
37
+ import { rawRun } from "../../../persistence/raw-query.js";
38
+ import { conversations } from "../../../persistence/schema/index.js";
39
+ import { ROUTES as CONVERSATION_LIST_ROUTES } from "../conversation-list-routes.js";
40
+ import type { RouteDefinition } from "../types.js";
41
+
42
+ await initializeDb();
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Helpers
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function findHandler(routes: RouteDefinition[], operationId: string) {
49
+ const route = routes.find((r) => r.operationId === operationId);
50
+ if (!route) {
51
+ throw new Error(`Route ${operationId} not found`);
52
+ }
53
+ return route.handler;
54
+ }
55
+
56
+ const sectionsHandler = findHandler(
57
+ CONVERSATION_LIST_ROUTES,
58
+ "getConversationSections",
59
+ );
60
+ const listHandler = findHandler(CONVERSATION_LIST_ROUTES, "listConversations");
61
+
62
+ interface SectionRow {
63
+ kind: "pinned" | "group" | "channel" | "chats" | "assistant";
64
+ channelId?: string;
65
+ groupId?: string;
66
+ total: number;
67
+ unread: number;
68
+ }
69
+
70
+ function invokeSections(): SectionRow[] {
71
+ return (sectionsHandler({}) as { sections: SectionRow[] }).sections;
72
+ }
73
+
74
+ interface ListResponse {
75
+ conversations: Array<{ id: string; title: string }>;
76
+ }
77
+
78
+ function invokeList(queryParams: Record<string, string> = {}): ListResponse {
79
+ return listHandler({ queryParams }) as ListResponse;
80
+ }
81
+
82
+ function titlesFrom(response: ListResponse): string[] {
83
+ return response.conversations.map((c) => c.title).sort();
84
+ }
85
+
86
+ /**
87
+ * A thread a producer opted into the assistant-initiated section: a
88
+ * standard-type conversation, filed nowhere, stamped
89
+ * `source: 'assistant_initiated'` at creation (ASSISTANT_INITIATED_SOURCE).
90
+ */
91
+ function seedAssistantInitiated(title: string): string {
92
+ return createConversation({ title, source: "assistant_initiated" }).id;
93
+ }
94
+
95
+ /**
96
+ * A transactional notification trail - what the guardian request flows and
97
+ * channel deliveries materialize (`source: 'notification'`, see
98
+ * `notifications/conversation-pairing.ts`). Never a member of the section:
99
+ * these belong to the bell and to Chats.
100
+ */
101
+ function seedNotificationTrail(title: string): string {
102
+ return createConversation({ title, source: "notification" }).id;
103
+ }
104
+
105
+ function seedUnseen(conversationId: string): void {
106
+ projectAssistantMessage({
107
+ conversationId,
108
+ messageId: `msg-${conversationId}`,
109
+ messageAt: Date.now(),
110
+ });
111
+ }
112
+
113
+ function stampChannel(conversationId: string, channel: string): void {
114
+ rawRun(
115
+ "test:stampChannel",
116
+ "UPDATE conversations SET origin_channel = ? WHERE id = ?",
117
+ channel,
118
+ conversationId,
119
+ );
120
+ }
121
+
122
+ function pin(conversationId: string): void {
123
+ rawRun(
124
+ "test:pinConversation",
125
+ "UPDATE conversations SET is_pinned = 1, group_id = 'system:pinned' WHERE id = ?",
126
+ conversationId,
127
+ );
128
+ }
129
+
130
+ function fileIntoGroup(conversationId: string, groupId: string): void {
131
+ rawRun(
132
+ "test:fileIntoGroup",
133
+ "UPDATE conversations SET group_id = ? WHERE id = ?",
134
+ groupId,
135
+ conversationId,
136
+ );
137
+ }
138
+
139
+ beforeEach(() => {
140
+ flagEnabled = false;
141
+ getDb().delete(conversations).run();
142
+ });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Flag off — the shipped behavior, unchanged
146
+ // ---------------------------------------------------------------------------
147
+
148
+ describe("assistant-initiated threads — flag off", () => {
149
+ test("emits no assistant section, and the threads stay counted in Chats", () => {
150
+ seedAssistantInitiated("overnight-realization");
151
+ createConversation({ title: "user-thread" });
152
+
153
+ const sections = invokeSections();
154
+
155
+ expect(sections.some((s) => s.kind === "assistant")).toBe(false);
156
+ expect(sections).toContainEqual({ kind: "chats", total: 2, unread: 0 });
157
+ });
158
+
159
+ test("the threads stay listed in the standard list", () => {
160
+ seedAssistantInitiated("overnight-realization");
161
+ createConversation({ title: "user-thread" });
162
+
163
+ expect(titlesFrom(invokeList())).toEqual([
164
+ "overnight-realization",
165
+ "user-thread",
166
+ ]);
167
+ });
168
+
169
+ test("their unread still counts toward Chats", () => {
170
+ seedUnseen(seedAssistantInitiated("noticed-something"));
171
+ createConversation({ title: "user-thread" });
172
+
173
+ expect(invokeSections()).toContainEqual({
174
+ kind: "chats",
175
+ total: 2,
176
+ unread: 1,
177
+ });
178
+ });
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Flag on — the split
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe("assistant-initiated threads — flag on", () => {
186
+ beforeEach(() => {
187
+ flagEnabled = true;
188
+ });
189
+
190
+ test("the threads move out of Chats into their own section", () => {
191
+ seedAssistantInitiated("overnight-realization");
192
+ seedAssistantInitiated("noticed-something");
193
+ createConversation({ title: "user-thread" });
194
+
195
+ const sections = invokeSections();
196
+
197
+ expect(sections).toContainEqual({ kind: "assistant", total: 2, unread: 0 });
198
+ expect(sections).toContainEqual({ kind: "chats", total: 1, unread: 0 });
199
+ });
200
+
201
+ test("the section renders at zero, the way Chats does", () => {
202
+ createConversation({ title: "user-thread" });
203
+
204
+ expect(invokeSections()).toContainEqual({
205
+ kind: "assistant",
206
+ total: 0,
207
+ unread: 0,
208
+ });
209
+ });
210
+
211
+ test("unread follows the section, and is not double-counted in Chats", () => {
212
+ seedUnseen(seedAssistantInitiated("noticed-something"));
213
+ seedAssistantInitiated("read-already");
214
+ seedUnseen(createConversation({ title: "user-thread" }).id);
215
+
216
+ const sections = invokeSections();
217
+
218
+ expect(sections).toContainEqual({ kind: "assistant", total: 2, unread: 1 });
219
+ expect(sections).toContainEqual({ kind: "chats", total: 1, unread: 1 });
220
+ });
221
+
222
+ test("the split leaves channel sections alone", () => {
223
+ seedAssistantInitiated("overnight-realization");
224
+ stampChannel(createConversation({ title: "slack-thread" }).id, "slack");
225
+
226
+ const sections = invokeSections();
227
+
228
+ expect(sections).toContainEqual({ kind: "assistant", total: 1, unread: 0 });
229
+ expect(sections).toContainEqual({
230
+ kind: "channel",
231
+ channelId: "slack",
232
+ total: 1,
233
+ unread: 0,
234
+ });
235
+ });
236
+
237
+ test("groupId=system:assistant lists exactly those threads", () => {
238
+ seedAssistantInitiated("overnight-realization");
239
+ seedAssistantInitiated("noticed-something");
240
+ createConversation({ title: "user-thread" });
241
+
242
+ expect(titlesFrom(invokeList({ groupId: "system:assistant" }))).toEqual([
243
+ "noticed-something",
244
+ "overnight-realization",
245
+ ]);
246
+ });
247
+
248
+ test("the standard list withholds them, and keeps every NULL-source row", () => {
249
+ // The regression this guards: `NOT (source = '...')` is NULL for the
250
+ // NULL-source rows that make up most of the list, so a naive negation
251
+ // would return an empty list rather than one section fewer.
252
+ seedAssistantInitiated("overnight-realization");
253
+ createConversation({ title: "user-thread" });
254
+ createConversation({ title: "another-user-thread" });
255
+
256
+ expect(titlesFrom(invokeList())).toEqual([
257
+ "another-user-thread",
258
+ "user-thread",
259
+ ]);
260
+ });
261
+
262
+ test("a list already scoped to the section is not narrowed to nothing", () => {
263
+ // `groupId=system:assistant` selects on the same column the exclusion
264
+ // negates, so applying both would return an empty page for the one caller
265
+ // that actually wants these rows.
266
+ seedAssistantInitiated("overnight-realization");
267
+
268
+ expect(titlesFrom(invokeList({ groupId: "system:assistant" }))).toEqual([
269
+ "overnight-realization",
270
+ ]);
271
+ });
272
+
273
+ test("a pinned thread belongs to Pinned, not to both", () => {
274
+ // Section membership is single-valued everywhere else in the sidebar, and
275
+ // this section is carved out of the same ungrouped set Chats is, so
276
+ // pinning has to move a thread rather than duplicate it.
277
+ pin(seedAssistantInitiated("pinned-realization"));
278
+ seedAssistantInitiated("ordinary-realization");
279
+
280
+ const sections = invokeSections();
281
+
282
+ expect(sections).toContainEqual({ kind: "assistant", total: 1, unread: 0 });
283
+ expect(sections).toContainEqual({ kind: "pinned", total: 1, unread: 0 });
284
+ expect(titlesFrom(invokeList({ groupId: "system:assistant" }))).toEqual([
285
+ "ordinary-realization",
286
+ ]);
287
+ });
288
+
289
+ test("transactional notification trails are not members, and stay in Chats", () => {
290
+ // The membership source is opt-in precisely so the flag turning on cannot
291
+ // sweep the historical guardian-request and channel-delivery trails
292
+ // (`source = 'notification'`) into the section: only threads written for
293
+ // the section, from the moment producers start stamping its source, are
294
+ // members.
295
+ seedNotificationTrail("approve-this-call");
296
+ seedAssistantInitiated("overnight-realization");
297
+
298
+ const sections = invokeSections();
299
+
300
+ expect(sections).toContainEqual({ kind: "assistant", total: 1, unread: 0 });
301
+ expect(sections).toContainEqual({ kind: "chats", total: 1, unread: 0 });
302
+ expect(titlesFrom(invokeList())).toEqual(["approve-this-call"]);
303
+ expect(titlesFrom(invokeList({ groupId: "system:assistant" }))).toEqual([
304
+ "overnight-realization",
305
+ ]);
306
+ });
307
+
308
+ test("a thread filed into a custom group leaves the section and stays listable", () => {
309
+ // The exclusion is the complement of the section, so a thread that moved
310
+ // out of the section must not stay excluded from the list of the group it
311
+ // moved into — otherwise filing one makes it unreachable.
312
+ const group = createGroup("Reading List");
313
+ const filed = seedAssistantInitiated("filed-realization");
314
+ fileIntoGroup(filed, group.id);
315
+
316
+ expect(invokeSections()).toContainEqual({
317
+ kind: "assistant",
318
+ total: 0,
319
+ unread: 0,
320
+ });
321
+ expect(titlesFrom(invokeList({ groupId: group.id }))).toEqual([
322
+ "filed-realization",
323
+ ]);
324
+ });
325
+
326
+ test("the Archive view keeps archived section threads", () => {
327
+ // Archive asks archiveStatus=archived with no groupId, and the section
328
+ // only ever lists active rows, so the split's exclusion must not reach
329
+ // archived reads or an archived section thread is visible nowhere.
330
+ const archived = seedAssistantInitiated("archived-realization");
331
+ rawRun(
332
+ "test:archive",
333
+ "UPDATE conversations SET archived_at = ? WHERE id = ?",
334
+ Date.now(),
335
+ archived,
336
+ );
337
+
338
+ expect(titlesFrom(invokeList({ archiveStatus: "archived" }))).toEqual([
339
+ "archived-realization",
340
+ ]);
341
+ });
342
+
343
+ test("the background umbrella is left unnarrowed", () => {
344
+ // A background row carrying the section's own source is addressed by
345
+ // conversation type, and narrowing that bucket by source would drop rows
346
+ // its callers still page through.
347
+ const background = createConversation({
348
+ title: "background-realization",
349
+ conversationType: "background",
350
+ source: "assistant_initiated",
351
+ });
352
+
353
+ expect(
354
+ invokeList({ conversationType: "background" }).conversations.map(
355
+ (c) => c.id,
356
+ ),
357
+ ).toContain(background.id);
358
+ });
359
+ });
@@ -13,6 +13,7 @@
13
13
  import { z } from "zod";
14
14
 
15
15
  import { CHANNEL_IDS } from "../../channels/types.js";
16
+ import { isAssistantInitiatedThreadsEnabled } from "../../config/assistant-initiated-threads-gate.js";
16
17
  import { channelBindingSchema } from "../../messaging/channel-binding-schema.js";
17
18
  import {
18
19
  type Confidence,
@@ -35,7 +36,10 @@ import {
35
36
  listConversations,
36
37
  listPinnedConversations,
37
38
  } from "../../persistence/conversation-queries.js";
38
- import type { ConversationType } from "../../persistence/conversation-types.js";
39
+ import {
40
+ ASSISTANT_INITIATED_GROUP_ID,
41
+ type ConversationType,
42
+ } from "../../persistence/conversation-types.js";
39
43
  import { getBindingsForConversations } from "../../persistence/external-conversation-store.js";
40
44
  import { listGroups } from "../../persistence/group-crud.js";
41
45
  import { UserError } from "../../util/errors.js";
@@ -170,9 +174,16 @@ const sectionCountFields = {
170
174
  * `chats` is the ungrouped-native bucket. In a view without channel sections
171
175
  * the Chats card holds every ungrouped row: the buckets are disjoint, so
172
176
  * that reading is `chats` plus the sum of `channel` entries.
177
+ *
178
+ * `assistant` is emitted only under the `assistant-initiated-threads` flag,
179
+ * and while it is emitted its rows are withheld from `chats`, so the
180
+ * disjointness above holds in both states. A client that does not know the
181
+ * kind ignores it, which is the same thing an older client does with a
182
+ * `channel` section for a channel it has no label for.
173
183
  */
174
184
  const conversationSectionSchema = z.discriminatedUnion("kind", [
175
185
  z.object({ kind: z.literal("pinned"), ...sectionCountFields }),
186
+ z.object({ kind: z.literal("assistant"), ...sectionCountFields }),
176
187
  z.object({
177
188
  kind: z.literal("group"),
178
189
  groupId: z.string(),
@@ -274,6 +285,23 @@ function handleListConversations({ queryParams = {} }: RouteHandlerArgs) {
274
285
  ? true
275
286
  : undefined;
276
287
 
288
+ /* The list half of the section split. A read already scoped to the
289
+ assistant section obviously keeps its rows; every other standard-listing
290
+ read withholds them, so they appear in their own section and nowhere
291
+ else. Confined to `standard`: the background and scheduled buckets are
292
+ back-compat umbrellas addressed by conversation type, and narrowing them
293
+ by source would drop rows their callers still page through. Confined to
294
+ active reads too: the Archive view asks `archiveStatus=archived` with no
295
+ `groupId`, and the section only ever lists active rows, so withholding
296
+ there would leave an archived section thread visible nowhere. */
297
+ const excludeAssistantInitiated =
298
+ isAssistantInitiatedThreadsEnabled() &&
299
+ conversationType === "standard" &&
300
+ archiveStatus === "active" &&
301
+ groupId !== ASSISTANT_INITIATED_GROUP_ID
302
+ ? (true as const)
303
+ : undefined;
304
+
277
305
  const filter: ConversationListFilter = {
278
306
  conversationType,
279
307
  archiveStatus,
@@ -281,6 +309,7 @@ function handleListConversations({ queryParams = {} }: RouteHandlerArgs) {
281
309
  groupId,
282
310
  needsAttention,
283
311
  foregroundOnly,
312
+ excludeAssistantInitiated,
284
313
  };
285
314
  let rows = listConversations({ ...filter, limit, offset });
286
315
  const totalCount = countConversations(filter);
@@ -371,10 +400,22 @@ function handleGetUnreadConversationCount() {
371
400
  * dangling id is a transient state around group deletion, not a section.
372
401
  */
373
402
  function handleGetConversationSections() {
374
- const counts = countConversationSections();
403
+ const splitAssistantInitiated = isAssistantInitiatedThreadsEnabled();
404
+ const counts = countConversationSections({ splitAssistantInitiated });
375
405
  const groupsById = new Map(listGroups().map((g) => [g.id, g]));
376
406
  const sections: Array<z.infer<typeof conversationSectionSchema>> = [];
377
407
 
408
+ /* Unlike every other section this one renders at zero, the way Chats does:
409
+ it is where the assistant's own threads accumulate, and a user who has
410
+ none yet is exactly who the section's empty state is written for. */
411
+ if (counts.assistantInitiated) {
412
+ sections.push({
413
+ kind: "assistant",
414
+ total: counts.assistantInitiated.total,
415
+ unread: counts.assistantInitiated.unread,
416
+ });
417
+ }
418
+
378
419
  for (const row of counts.groups) {
379
420
  if (row.groupId === "system:pinned") {
380
421
  sections.push({ kind: "pinned", total: row.total, unread: row.unread });
@@ -655,7 +696,7 @@ export const ROUTES: RouteDefinition[] = [
655
696
  },
656
697
  summary: "Sidebar section index",
657
698
  description:
658
- "One row per renderable sidebar section (Pinned, each non-empty custom group, each origin channel with unassigned conversations, Chats) with total and unread counts and no conversation rows. Lets a client know which sections exist, and what their badges say, without fetching any conversation list. Totals follow the standard listing visibility; unread applies the same rules as GET /v1/conversations/unread-count scoped to the section. Chats is always present, even at zero: it is the leftover bucket and renders regardless.",
699
+ "One row per renderable sidebar section (Pinned, each non-empty custom group, each origin channel with unassigned conversations, Chats) with total and unread counts and no conversation rows. Lets a client know which sections exist, and what their badges say, without fetching any conversation list. Totals follow the standard listing visibility; unread applies the same rules as GET /v1/conversations/unread-count scoped to the section. Chats is always present, even at zero: it is the leftover bucket and renders regardless. Under the `assistant-initiated-threads` flag an `assistant` section is also present at every count — the threads the assistant started on its own, selectable as a list via `groupId=system:assistant` — and its rows are withheld from Chats so the buckets stay disjoint. With the flag off no `assistant` row is emitted and those conversations are counted in Chats as before.",
659
700
  tags: ["conversations"],
660
701
  responseBody: conversationSectionsResponseSchema,
661
702
  handler: handleGetConversationSections,