@vellumai/assistant 0.12.5-dev.202609251714.4e37a15 → 0.12.5-dev.202609251913.90d2e9d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/ARCHITECTURE.md +17 -0
  2. package/docs/automatic-done.md +32 -0
  3. package/openapi.yaml +75 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/conversation-evictor.test.ts +54 -0
  6. package/src/__tests__/emit-signal-routing-intent.test.ts +111 -0
  7. package/src/__tests__/gateway-flag-listener.test.ts +59 -6
  8. package/src/__tests__/identity-routes.test.ts +4 -3
  9. package/src/__tests__/notification-platform-adapter.test.ts +83 -1
  10. package/src/config/__tests__/chat-settings.test.ts +121 -0
  11. package/src/config/chat-settings.ts +100 -0
  12. package/src/config/loader.ts +5 -1
  13. package/src/config/schemas/conversations.ts +14 -0
  14. package/src/config/schemas/notifications.ts +8 -1
  15. package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
  16. package/src/conversations/__tests__/auto-archive.test.ts +304 -0
  17. package/src/conversations/auto-archive-activity.ts +61 -0
  18. package/src/conversations/auto-archive.ts +201 -0
  19. package/src/daemon/conversation-evictor.ts +50 -16
  20. package/src/daemon/lifecycle.ts +4 -1
  21. package/src/daemon/shutdown-handlers.ts +3 -0
  22. package/src/daemon/turn-finalization.ts +4 -0
  23. package/src/ipc/gateway-flag-listener.ts +7 -10
  24. package/src/notifications/README.md +12 -1
  25. package/src/notifications/__tests__/broadcaster.test.ts +259 -2
  26. package/src/notifications/adapters/platform.ts +14 -0
  27. package/src/notifications/broadcaster.ts +28 -9
  28. package/src/notifications/chat-reply-policy.ts +11 -0
  29. package/src/notifications/completion-policy.ts +5 -1
  30. package/src/notifications/types.ts +2 -0
  31. package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
  32. package/src/persistence/conversation-auto-archive.ts +170 -0
  33. package/src/persistence/conversation-crud.ts +2 -1
  34. package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
  35. package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
  36. package/src/persistence/schema/conversations.ts +1 -0
  37. package/src/persistence/steps.ts +2 -0
  38. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
  39. package/src/runtime/routes/conversation-query-routes.ts +48 -10
  40. package/src/runtime/routes/identity-routes.ts +2 -0
package/ARCHITECTURE.md CHANGED
@@ -906,6 +906,23 @@ graph LR
906
906
 
907
907
  ---
908
908
 
909
+ ## Automatic Done
910
+
911
+ `src/conversations/auto-archive.ts` runs an opt-in automatic Done sweep after startup recovery, on assistant config or feature-flag invalidation, and hourly. It marks eligible inactive native chats through the existing `archived_at` state. Candidate selection and the conditional update share the same persisted eligibility predicates; runtime checks run synchronously with each conditional write. Explicit reopening records `last_reopened_at` to grant a full inactivity interval.
912
+
913
+ ```mermaid
914
+ flowchart LR
915
+ TRIGGER[Startup recovery / config or feature-flag change / hourly timer] --> PAGE[Bounded candidate page]
916
+ PAGE --> GUARDIAN[Read pending guardian requests]
917
+ GUARDIAN --> CHECK[Recheck config and runtime activity]
918
+ CHECK --> CAS[Conditional SQLite Done update]
919
+ CAS --> SYNC[Conversation list and metadata invalidation]
920
+ ```
921
+
922
+ Readiness, eligibility, restart behavior, and the gateway snapshot boundary are documented in [Automatic Done](docs/automatic-done.md).
923
+
924
+ ---
925
+
909
926
  ## Plugin-Declared Schedules: Declaration → Reconciler → cron_jobs
910
927
 
911
928
  Plugins contribute recurring schedules as a surface: directories under
@@ -0,0 +1,32 @@
1
+ # Automatic Done
2
+
3
+ Chats Settings exposes `conversations.autoArchive` through the assistant config. `enabled` defaults to `false`; `afterDays` defaults to `7` and accepts `1`, `7`, `14`, or `30`. The worker additionally requires the `sidebar-done` feature flag so the setting cannot activate legacy archive behavior.
4
+
5
+ Auto Archive marks eligible inactive chats **Done** by setting the existing `archived_at` field. The chat remains visible in All Chats and Done, retains its transcript, and can be reopened. No schedule is cancelled and no data is deleted. New visible activity uses the existing resurfacing path.
6
+
7
+ ## Eligibility
8
+
9
+ Inactivity is elapsed wall time since the latest of the last message (or creation for an empty chat) and an explicit reopen. Title edits, read receipts, config edits and other `updated_at` writes do not count as conversation activity. Migration `386-conversations-last-reopened-at.ts` adds a nullable marker without backfilling or changing existing chats. Only an actual Done-to-open transition records that marker, giving a reopened chat a full configured interval.
10
+
11
+ Candidates must be unarchived standard native chats. Native notification-origin chats are eligible; an external channel binding takes precedence over the legacy origin field. Pinned, private, scheduled, background, system, consolidation and child/subagent conversations are excluded. Chats with unread assistant output, pending guardian input, a live ACP session, a running workflow, an active mode session or a nonterminal subagent are excluded.
12
+
13
+ The final runtime check also excludes accepted sends, queued/processing turns, voice residency, pending standalone surfaces, local interactions, background tool work, wake dispatches and unfinished turn finalization. ACP resume reservations are checked against their persisted parent before the resumed session registers as live.
14
+
15
+ ## Lifecycle and concurrency
16
+
17
+ The assistant owns one worker, independent of client pages. Its first sweep waits for interrupted conversation recovery, database migration readiness and startup completion. Failed startup recovery leaves the worker paused until restart. Config or assistant feature-flag invalidation and an hourly timer trigger later sweeps; overlapping triggers coalesce. The gateway flag listener publishes invalidation only after its cache refresh succeeds, including reconnect catch-up, so consumers see the refreshed gate. A failed refresh emits no invalidation. Shutdown cancels retries, removes the subscription/timer and prevents a late recovery promise from starting work.
18
+
19
+ Each sweep scans bounded pages of 100 candidates with an activity/id keyset. For every page, it reads the exhaustive pending guardian-request list through the gateway. A failed read leaves that page untouched. Final eligibility and the write run synchronously inside each SQLite retry attempt. The SQL compare-and-set checks sampled message/reopen cursors and all persisted eligibility again, so activity, pinning, input-related processing, workflow/session changes, or reopening during the asynchronous gateway read cannot overwrite newer state.
20
+
21
+ Gateway-owned guardian requests live outside the assistant database. The page read is a snapshot, not a distributed transaction: a remote request created after that read cannot be atomically excluded by SQLite. Local interaction, processing and queue checks close the ordinary same-process window. The worker fails closed on an unavailable gateway or uncertain runtime state; the cross-service snapshot boundary remains explicit.
22
+
23
+ Successful updates publish the existing conversation-list and per-conversation metadata invalidations once per changed page, including updates committed before a later failure. There is no new transport event or persistent worker cursor. A restart safely scans eligible rows again, and already Done rows are excluded.
24
+
25
+ ## Code and validation
26
+
27
+ - `src/conversations/auto-archive.ts`: scheduling, startup barrier, guardian snapshot and invalidation batching.
28
+ - `src/conversations/auto-archive-activity.ts`: synchronous runtime blockers.
29
+ - `src/persistence/conversation-auto-archive.ts`: shared SQL eligibility, paging and conditional writes.
30
+ - `src/persistence/conversation-crud.ts`: explicit reopen marker and existing activity resurfacing.
31
+
32
+ Focused tests cover eligibility and cursor races, reopen grace, paging, pending input, retries, lifecycle cancellation, coalescing and client invalidation. Migration tests verify existing data and idempotence.
package/openapi.yaml CHANGED
@@ -14462,6 +14462,8 @@ paths:
14462
14462
  type: boolean
14463
14463
  appPins:
14464
14464
  type: boolean
14465
+ chatsSettings:
14466
+ type: boolean
14465
14467
  required:
14466
14468
  - memoryOptOut
14467
14469
  - retryLastTurn
@@ -14696,6 +14698,8 @@ paths:
14696
14698
  type: boolean
14697
14699
  appPins:
14698
14700
  type: boolean
14701
+ chatsSettings:
14702
+ type: boolean
14699
14703
  required:
14700
14704
  - memoryOptOut
14701
14705
  - retryLastTurn
@@ -38727,6 +38731,41 @@ components:
38727
38731
  ConfigPatchRequest:
38728
38732
  type: object
38729
38733
  properties:
38734
+ conversations:
38735
+ anyOf:
38736
+ - type: object
38737
+ properties:
38738
+ autoArchive:
38739
+ anyOf:
38740
+ - type: object
38741
+ properties:
38742
+ enabled:
38743
+ anyOf:
38744
+ - type: boolean
38745
+ - type: "null"
38746
+ afterDays:
38747
+ anyOf:
38748
+ - type: number
38749
+ enum:
38750
+ - 1
38751
+ - 7
38752
+ - 14
38753
+ - 30
38754
+ - type: "null"
38755
+ additionalProperties: {}
38756
+ - type: "null"
38757
+ additionalProperties: {}
38758
+ - type: "null"
38759
+ notifications:
38760
+ anyOf:
38761
+ - type: object
38762
+ properties:
38763
+ newMessageEnabled:
38764
+ anyOf:
38765
+ - type: boolean
38766
+ - type: "null"
38767
+ additionalProperties: {}
38768
+ - type: "null"
38730
38769
  llm:
38731
38770
  type: object
38732
38771
  properties:
@@ -39460,6 +39499,42 @@ components:
39460
39499
  ConfigGetResponse:
39461
39500
  type: object
39462
39501
  properties:
39502
+ conversations:
39503
+ type: object
39504
+ properties:
39505
+ autoArchive:
39506
+ type: object
39507
+ properties:
39508
+ enabled:
39509
+ default: false
39510
+ description: Automatically mark eligible inactive chats Done.
39511
+ type: boolean
39512
+ afterDays:
39513
+ default: 7
39514
+ description: Elapsed days of inactivity before a chat is eligible for Done.
39515
+ type: number
39516
+ enum:
39517
+ - 1
39518
+ - 7
39519
+ - 14
39520
+ - 30
39521
+ required:
39522
+ - enabled
39523
+ - afterDays
39524
+ additionalProperties: {}
39525
+ required:
39526
+ - autoArchive
39527
+ additionalProperties: {}
39528
+ notifications:
39529
+ type: object
39530
+ properties:
39531
+ newMessageEnabled:
39532
+ default: true
39533
+ description: Allow ordinary chat-reply alerts. Does not change unread state or action-required alerts.
39534
+ type: boolean
39535
+ required:
39536
+ - newMessageEnabled
39537
+ additionalProperties: {}
39463
39538
  llm:
39464
39539
  type: object
39465
39540
  properties:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.5-dev.202609251714.4e37a15",
3
+ "version": "0.12.5-dev.202609251913.90d2e9d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,6 +12,7 @@ mock.module("../subagent/index.js", () => ({
12
12
 
13
13
  import {
14
14
  ConversationEvictor,
15
+ defaultMemoryThresholdBytes,
15
16
  type EvictableConversation,
16
17
  } from "../daemon/conversation-evictor.js";
17
18
 
@@ -268,6 +269,46 @@ describe("ConversationEvictor", () => {
268
269
  expect(resident.disposed).toBe(false);
269
270
  expect(idle.disposed).toBe(true);
270
271
  });
272
+
273
+ test("evicts a bounded batch per sweep, least recently used first", () => {
274
+ for (let i = 0; i < 15; i++) {
275
+ sessions.set(`conv-${i}`, createMockSession());
276
+ }
277
+ evictor = new ConversationEvictor(sessions, {
278
+ ttlMs: Number.MAX_SAFE_INTEGER,
279
+ maxConversations: Number.MAX_SAFE_INTEGER,
280
+ memoryThresholdBytes: 0,
281
+ });
282
+ for (let i = 0; i < 15; i++) {
283
+ evictor.touch(`conv-${i}`);
284
+ }
285
+
286
+ expect(evictor.sweep().memoryEvicted).toBe(10);
287
+ expect(sessions.has("conv-0")).toBe(false);
288
+ expect(sessions.has("conv-10")).toBe(true);
289
+ expect(evictor.sweep().memoryEvicted).toBe(5);
290
+ expect(sessions.size).toBe(0);
291
+ });
292
+
293
+ test("derives the threshold from the container limit at first sweep, not construction", () => {
294
+ const previous = process.env.VELLUM_MEMORY_LIMIT;
295
+ sessions.set("idle", createMockSession());
296
+ evictor = new ConversationEvictor(sessions, {
297
+ ttlMs: Number.MAX_SAFE_INTEGER,
298
+ maxConversations: Number.MAX_SAFE_INTEGER,
299
+ });
300
+ // Half of 1 MiB is far below any live RSS, so the phase must fire.
301
+ process.env.VELLUM_MEMORY_LIMIT = "1Mi";
302
+ try {
303
+ expect(evictor.sweep().memoryEvicted).toBe(1);
304
+ } finally {
305
+ if (previous === undefined) {
306
+ delete process.env.VELLUM_MEMORY_LIMIT;
307
+ } else {
308
+ process.env.VELLUM_MEMORY_LIMIT = previous;
309
+ }
310
+ }
311
+ });
271
312
  });
272
313
 
273
314
  describe("onEvict", () => {
@@ -326,3 +367,16 @@ describe("ConversationEvictor", () => {
326
367
  });
327
368
  });
328
369
  });
370
+
371
+ describe("defaultMemoryThresholdBytes", () => {
372
+ const GIB = 1024 * 1024 * 1024;
373
+
374
+ test("is half the container limit", () => {
375
+ expect(defaultMemoryThresholdBytes(3 * GIB)).toBe(1.5 * GIB);
376
+ expect(defaultMemoryThresholdBytes(8 * GIB)).toBe(4 * GIB);
377
+ });
378
+
379
+ test("falls back to 3 GiB without a limit", () => {
380
+ expect(defaultMemoryThresholdBytes(null)).toBe(3 * GIB);
381
+ });
382
+ });
@@ -1,5 +1,8 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import { saveRawConfig } from "../config/loader.js";
4
+ import type { NotificationChannel } from "../notifications/types.js";
5
+
3
6
  const evaluateSignalMock = mock();
4
7
  const enforceRoutingIntentMock = mock();
5
8
  const updateDecisionMock = mock();
@@ -8,6 +11,14 @@ const createEventMock = mock();
8
11
  const setEventDedupeKeyMock = mock();
9
12
  const dispatchDecisionMock = mock();
10
13
  const isPlatformClientConfiguredMock = mock();
14
+ const writeHomeFeedItemForSignalMock = mock(
15
+ async (..._args: unknown[]) => null,
16
+ );
17
+
18
+ mock.module("../notifications/home-feed-side-effect.js", () => ({
19
+ writeHomeFeedItemForSignal: (...args: unknown[]) =>
20
+ writeHomeFeedItemForSignalMock(...args),
21
+ }));
11
22
 
12
23
  mock.module("../channels/config.js", () => ({
13
24
  getDeliverableChannels: () => ["vellum", "telegram"],
@@ -101,6 +112,8 @@ import {
101
112
  } from "../notifications/emit-signal.js";
102
113
 
103
114
  beforeEach(() => {
115
+ saveRawConfig({});
116
+ writeHomeFeedItemForSignalMock.mockClear();
104
117
  evaluateSignalMock.mockReset();
105
118
  enforceRoutingIntentMock.mockReset();
106
119
  updateDecisionMock.mockReset();
@@ -121,6 +134,104 @@ beforeEach(() => {
121
134
  enforceRoutingIntentMock.mockImplementation((decision: unknown) => decision);
122
135
  });
123
136
 
137
+ describe("emitNotificationSignal chat reply preference", () => {
138
+ test.each([
139
+ [false, false],
140
+ [true, true],
141
+ [false, true],
142
+ [true, false],
143
+ ])(
144
+ "preserves routing candidates when the setting changes %s -> %s",
145
+ async (initial, enabled) => {
146
+ saveRawConfig({ notifications: { newMessageEnabled: initial } });
147
+ const selectedChannels: NotificationChannel[] = [
148
+ "vellum",
149
+ "platform",
150
+ "telegram",
151
+ ];
152
+ const decision = {
153
+ shouldNotify: true,
154
+ selectedChannels,
155
+ reasoningSummary: "Selected reply delivery",
156
+ renderedCopy: {
157
+ vellum: { title: "Answer", body: "The result is ready." },
158
+ },
159
+ dedupeKey: "reply-1",
160
+ confidence: 1,
161
+ fallbackUsed: false,
162
+ persistedDecisionId: "decision-1",
163
+ };
164
+ evaluateSignalMock.mockResolvedValue(decision);
165
+ enforceRoutingIntentMock.mockReturnValue(decision);
166
+ runDeterministicChecksMock.mockImplementation(async () => {
167
+ await Promise.resolve();
168
+ saveRawConfig({ notifications: { newMessageEnabled: enabled } });
169
+ return { passed: true };
170
+ });
171
+ const result = await emitNotificationSignal({
172
+ sourceEventName: "chat.assistant_reply",
173
+ sourceChannel: "vellum",
174
+ sourceContextId: "conv-1",
175
+ attentionHints: {
176
+ urgency: "high",
177
+ requiresAction: false,
178
+ isAsyncBackground: false,
179
+ visibleInSourceNow: false,
180
+ },
181
+ routingIntent: "all_channels",
182
+ });
183
+ expect(runDeterministicChecksMock.mock.calls[0]?.[1]).toMatchObject({
184
+ selectedChannels: decision.selectedChannels,
185
+ });
186
+ expect(result.selectedChannels).toEqual(selectedChannels);
187
+ expect(result.dispatched).toBe(true);
188
+ expect(dispatchDecisionMock.mock.calls[0]?.[1]).toMatchObject({
189
+ shouldNotify: true,
190
+ selectedChannels,
191
+ });
192
+ expect(writeHomeFeedItemForSignalMock).toHaveBeenCalledTimes(1);
193
+ expect(writeHomeFeedItemForSignalMock.mock.calls[0]?.[1]).toMatchObject({
194
+ shouldNotify: true,
195
+ selectedChannels,
196
+ });
197
+ expect(updateDecisionMock).not.toHaveBeenCalled();
198
+ },
199
+ );
200
+
201
+ test.each([
202
+ "schedule.result",
203
+ "guardian.question",
204
+ "ingress.access_request",
205
+ "activity.failed",
206
+ "chat.assistant_reply.extra",
207
+ ])("preserves platform selection for %s", async (sourceEventName) => {
208
+ saveRawConfig({ notifications: { newMessageEnabled: false } });
209
+ evaluateSignalMock.mockResolvedValue({
210
+ shouldNotify: true,
211
+ selectedChannels: ["vellum", "platform"],
212
+ reasoningSummary: "Urgent delivery",
213
+ renderedCopy: { vellum: { title: "Alert", body: "Action required." } },
214
+ dedupeKey: "alert-1",
215
+ confidence: 1,
216
+ fallbackUsed: false,
217
+ persistedDecisionId: "decision-1",
218
+ });
219
+ const result = await emitNotificationSignal({
220
+ sourceEventName,
221
+ sourceChannel: "vellum",
222
+ sourceContextId: "conv-1",
223
+ attentionHints: {
224
+ urgency: "critical",
225
+ requiresAction: true,
226
+ isAsyncBackground: false,
227
+ visibleInSourceNow: false,
228
+ },
229
+ });
230
+ expect(result.selectedChannels).toEqual(["vellum", "platform"]);
231
+ expect(updateDecisionMock).not.toHaveBeenCalled();
232
+ });
233
+ });
234
+
124
235
  describe("emitNotificationSignal routing intent re-persistence", () => {
125
236
  test("re-persists selectedChannels/reasoningSummary when enforcement changes the decision", async () => {
126
237
  const preDecision = {
@@ -26,11 +26,12 @@ mock.module("../ipc/socket-path.js", () => ({
26
26
  // ---------------------------------------------------------------------------
27
27
  let refreshCallCount = 0;
28
28
  let refreshReturnsLoaded = true;
29
+ let pendingRefresh: Promise<boolean> | undefined;
29
30
 
30
31
  mock.module("../config/assistant-feature-flags.js", () => ({
31
32
  refreshOverridesFromGateway: async () => {
32
33
  refreshCallCount++;
33
- return refreshReturnsLoaded;
34
+ return pendingRefresh ?? refreshReturnsLoaded;
34
35
  },
35
36
  initFeatureFlagOverrides: async () => true,
36
37
  clearFeatureFlagOverridesCache: () => {},
@@ -136,6 +137,7 @@ describe("gateway-flag-listener", () => {
136
137
  mkdirSync(testRoot, { recursive: true });
137
138
  refreshCallCount = 0;
138
139
  refreshReturnsLoaded = true;
140
+ pendingRefresh = undefined;
139
141
  publishedTagSets = [];
140
142
  reconcileCallCount = 0;
141
143
  reconcileReturns = false;
@@ -177,7 +179,7 @@ describe("gateway-flag-listener", () => {
177
179
  expect(refreshCallCount).toBe(2);
178
180
  });
179
181
 
180
- test("broadcasts feature-flags sync_changed when flags change", async () => {
182
+ test("broadcasts feature-flags sync_changed after connect and flag changes", async () => {
181
183
  await new Promise<void>((resolve) => {
182
184
  testServer.server.listen(socketPath, resolve);
183
185
  });
@@ -186,19 +188,59 @@ describe("gateway-flag-listener", () => {
186
188
  await testServer.waitForClient();
187
189
  await new Promise((r) => setTimeout(r, 100));
188
190
 
189
- // Connect refresh should not broadcast — only an actual change does.
190
- expect(publishedTagSets.length).toBe(0);
191
+ expect(publishedTagSets.length).toBe(1);
191
192
 
192
193
  testServer.emit("feature_flags_changed");
193
194
  await new Promise((r) => setTimeout(r, 100));
194
195
 
195
- expect(publishedTagSets.length).toBe(1);
196
- expect(publishedTagSets[0]).toEqual([
196
+ expect(publishedTagSets.length).toBe(2);
197
+ expect(publishedTagSets[1]).toEqual([
197
198
  "feature-flags:client",
198
199
  "feature-flags:assistant",
199
200
  ]);
200
201
  });
201
202
 
203
+ test("defers invalidation until the flag-change refresh settles", async () => {
204
+ await new Promise<void>((resolve) => {
205
+ testServer.server.listen(socketPath, resolve);
206
+ });
207
+ startGatewayFlagListener();
208
+ await testServer.waitForClient();
209
+ await new Promise((resolve) => setTimeout(resolve, 100));
210
+ publishedTagSets = [];
211
+ const refresh = Promise.withResolvers<boolean>();
212
+ pendingRefresh = refresh.promise;
213
+ testServer.emit("feature_flags_changed");
214
+ await new Promise((resolve) => setTimeout(resolve, 100));
215
+ expect(refreshCallCount).toBe(2);
216
+ expect(publishedTagSets).toEqual([]);
217
+ expect(reconcileCallCount).toBe(1);
218
+ refresh.resolve(true);
219
+ await new Promise((resolve) => setTimeout(resolve, 0));
220
+ expect(publishedTagSets).toEqual([
221
+ ["feature-flags:client", "feature-flags:assistant"],
222
+ ]);
223
+ expect(reconcileCallCount).toBe(2);
224
+ });
225
+
226
+ test("defers connection invalidation until the refreshed cache is available", async () => {
227
+ const refresh = Promise.withResolvers<boolean>();
228
+ pendingRefresh = refresh.promise;
229
+ await new Promise<void>((resolve) => {
230
+ testServer.server.listen(socketPath, resolve);
231
+ });
232
+ startGatewayFlagListener();
233
+ await testServer.waitForClient();
234
+ await new Promise((resolve) => setTimeout(resolve, 100));
235
+ expect(refreshCallCount).toBe(1);
236
+ expect(publishedTagSets).toEqual([]);
237
+ refresh.resolve(true);
238
+ await new Promise((resolve) => setTimeout(resolve, 0));
239
+ expect(publishedTagSets).toEqual([
240
+ ["feature-flags:client", "feature-flags:assistant"],
241
+ ]);
242
+ });
243
+
202
244
  test("reconciles flag-gated profiles and broadcasts config_changed when the profile set changes", async () => {
203
245
  reconcileReturns = true;
204
246
  await new Promise<void>((resolve) => {
@@ -251,12 +293,22 @@ describe("gateway-flag-listener", () => {
251
293
  // The profile reconcile/broadcast are gated on flags actually loading.
252
294
  expect(reconcileCallCount).toBe(0);
253
295
  expect(configChangedCount).toBe(0);
296
+ expect(publishedTagSets).toEqual([]);
254
297
 
255
298
  testServer.emit("feature_flags_changed");
256
299
  await new Promise((r) => setTimeout(r, 100));
257
300
 
258
301
  expect(reconcileCallCount).toBe(0);
259
302
  expect(configChangedCount).toBe(0);
303
+ expect(publishedTagSets).toEqual([]);
304
+ refreshReturnsLoaded = true;
305
+ testServer.emit("feature_flags_changed");
306
+ await new Promise((resolve) => setTimeout(resolve, 100));
307
+ expect(reconcileCallCount).toBe(1);
308
+ expect(configChangedCount).toBe(1);
309
+ expect(publishedTagSets).toEqual([
310
+ ["feature-flags:client", "feature-flags:assistant"],
311
+ ]);
260
312
  });
261
313
 
262
314
  test("reconciles profiles when the refresh reports flags loaded", async () => {
@@ -318,6 +370,7 @@ describe("gateway-flag-listener", () => {
318
370
  await new Promise((r) => setTimeout(r, 100));
319
371
  const countAfterReconnect = refreshCallCount;
320
372
  expect(countAfterReconnect).toBeGreaterThan(0);
373
+ expect(publishedTagSets).toHaveLength(countAfterReconnect);
321
374
 
322
375
  const payload = JSON.stringify({ event: "feature_flags_changed" }) + "\n";
323
376
  secondClient!.write(payload);
@@ -166,6 +166,7 @@ describe("identity routes — health endpoint", () => {
166
166
  memoryOptOut: true,
167
167
  retryLastTurn: true,
168
168
  appPins: true,
169
+ chatsSettings: true,
169
170
  });
170
171
 
171
172
  // Profiler should either be absent or show enabled: false
@@ -492,9 +493,9 @@ describe("identity routes — /readyz readiness gate", () => {
492
493
  expect(body.ready).toBe(false);
493
494
  expect(body.reason).toBe("db_migrations_failed");
494
495
  expect((body.dbMigrations as Record<string, unknown>).state).toBe("failed");
495
- expect((body.dbMigrations as Record<string, unknown>).failedMigrations).toEqual(
496
- [{ name: "flakyStep", error: "transient failure" }],
497
- );
496
+ expect(
497
+ (body.dbMigrations as Record<string, unknown>).failedMigrations,
498
+ ).toEqual([{ name: "flakyStep", error: "transient failure" }]);
498
499
  });
499
500
 
500
501
  test("returns 200 even if CES is down", async () => {
@@ -1,11 +1,14 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import { saveRawConfig } from "../config/loader.js";
4
+
3
5
  // ── Mock logger ──────────────────────────────────────────────────────────────
4
6
 
5
7
  // ── Mock sleep so retry tests don't slow down the suite ──────────────────────
6
8
 
9
+ let onRetrySleep: () => Promise<void> = async () => {};
7
10
  mock.module("../util/retry.js", () => ({
8
- sleep: async (_ms: number): Promise<void> => {},
11
+ sleep: async (_ms: number): Promise<void> => onRetrySleep(),
9
12
  isRetryableStatus: (status: number): boolean =>
10
13
  status === 429 || status >= 500,
11
14
  isRetryableNetworkError: (error: unknown): boolean => {
@@ -41,10 +44,12 @@ const fetchResponses: Array<{ ok: boolean; status: number; body?: string }> =
41
44
  [];
42
45
  const fetchErrors: Error[] = [];
43
46
  let clientAvailable = true;
47
+ let onClientCreation: () => Promise<void> = async () => {};
44
48
 
45
49
  mock.module("../platform/client.js", () => ({
46
50
  VellumPlatformClient: {
47
51
  create: async () => {
52
+ await onClientCreation();
48
53
  if (!clientAvailable) {
49
54
  return null;
50
55
  }
@@ -117,12 +122,89 @@ function makeDestination(
117
122
 
118
123
  describe("PlatformPushAdapter", () => {
119
124
  beforeEach(() => {
125
+ saveRawConfig({});
120
126
  fetchCalls.length = 0;
121
127
  fetchResponses.length = 0;
122
128
  fetchErrors.length = 0;
123
129
  clientAvailable = true;
130
+ onRetrySleep = async () => {};
131
+ onClientCreation = async () => {};
132
+ });
133
+
134
+ test("skips a disabled chat reply without sending a native push", async () => {
135
+ saveRawConfig({ notifications: { newMessageEnabled: false } });
136
+ const result = await new PlatformPushAdapter().send(
137
+ makePayload({ sourceEventName: "chat.assistant_reply" }),
138
+ makeDestination(),
139
+ );
140
+ expect(result).toMatchObject({
141
+ success: false,
142
+ skipped: true,
143
+ remotePushAccepted: false,
144
+ });
145
+ expect(fetchCalls).toHaveLength(0);
146
+ });
147
+
148
+ test("rechecks the setting after platform client creation", async () => {
149
+ onClientCreation = async () => {
150
+ saveRawConfig({ notifications: { newMessageEnabled: false } });
151
+ };
152
+ const result = await new PlatformPushAdapter().send(
153
+ makePayload({ sourceEventName: "chat.assistant_reply" }),
154
+ makeDestination(),
155
+ );
156
+ expect(result).toMatchObject({
157
+ success: false,
158
+ skipped: true,
159
+ remotePushAccepted: false,
160
+ });
161
+ expect(fetchCalls).toHaveLength(0);
124
162
  });
125
163
 
164
+ test.each([false, true])(
165
+ "stops retries after disabling replies (accepted push: %s)",
166
+ async (accepted) => {
167
+ fetchResponses.push({
168
+ ok: false,
169
+ status: 503,
170
+ body: accepted ? '{"accepted_platforms":["ios"]}' : "{}",
171
+ });
172
+ onRetrySleep = async () => {
173
+ saveRawConfig({ notifications: { newMessageEnabled: false } });
174
+ };
175
+ const result = await new PlatformPushAdapter().send(
176
+ makePayload({ sourceEventName: "chat.assistant_reply" }),
177
+ makeDestination(),
178
+ );
179
+ expect(fetchCalls).toHaveLength(1);
180
+ expect(result).toMatchObject({
181
+ success: accepted,
182
+ skipped: !accepted,
183
+ remotePushAccepted: accepted,
184
+ remotePushPlatforms: accepted ? ["ios"] : undefined,
185
+ });
186
+ },
187
+ );
188
+
189
+ test.each([
190
+ "schedule.result",
191
+ "schedule.notify",
192
+ "guardian.question",
193
+ "activity.failed",
194
+ "chat.assistant_reply.extra",
195
+ ])(
196
+ "keeps %s native push enabled when ordinary reply alerts are off",
197
+ async (sourceEventName) => {
198
+ saveRawConfig({ notifications: { newMessageEnabled: false } });
199
+ const result = await new PlatformPushAdapter().send(
200
+ makePayload({ sourceEventName }),
201
+ makeDestination(),
202
+ );
203
+ expect(result.success).toBe(true);
204
+ expect(fetchCalls).toHaveLength(1);
205
+ },
206
+ );
207
+
126
208
  test("channel is 'platform'", () => {
127
209
  expect(new PlatformPushAdapter().channel).toBe("platform");
128
210
  });