@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 +19 -1
- package/package.json +1 -1
- package/src/__tests__/conversation-pairing.test.ts +141 -1
- package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
- package/src/config/assistant-initiated-threads-gate.ts +50 -0
- package/src/config/feature-flag-registry.json +9 -0
- package/src/notifications/conversation-pairing.ts +68 -2
- package/src/persistence/conversation-queries.ts +140 -11
- package/src/persistence/conversation-types.ts +36 -4
- package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
- package/src/runtime/routes/conversation-list-routes.ts +44 -3
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
|
@@ -19,9 +19,17 @@ let addMessageShouldThrow = false;
|
|
|
19
19
|
/** Simulated existing conversations for getConversation mock. */
|
|
20
20
|
let mockExistingConversations: Record<
|
|
21
21
|
string,
|
|
22
|
-
{
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
| "
|
|
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
|
|
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
|
|
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,
|