@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.
- package/ARCHITECTURE.md +17 -0
- package/docs/automatic-done.md +32 -0
- package/openapi.yaml +75 -0
- package/package.json +1 -1
- package/src/__tests__/conversation-evictor.test.ts +54 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +111 -0
- package/src/__tests__/gateway-flag-listener.test.ts +59 -6
- package/src/__tests__/identity-routes.test.ts +4 -3
- package/src/__tests__/notification-platform-adapter.test.ts +83 -1
- package/src/config/__tests__/chat-settings.test.ts +121 -0
- package/src/config/chat-settings.ts +100 -0
- package/src/config/loader.ts +5 -1
- package/src/config/schemas/conversations.ts +14 -0
- package/src/config/schemas/notifications.ts +8 -1
- package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
- package/src/conversations/__tests__/auto-archive.test.ts +304 -0
- package/src/conversations/auto-archive-activity.ts +61 -0
- package/src/conversations/auto-archive.ts +201 -0
- package/src/daemon/conversation-evictor.ts +50 -16
- package/src/daemon/lifecycle.ts +4 -1
- package/src/daemon/shutdown-handlers.ts +3 -0
- package/src/daemon/turn-finalization.ts +4 -0
- package/src/ipc/gateway-flag-listener.ts +7 -10
- package/src/notifications/README.md +12 -1
- package/src/notifications/__tests__/broadcaster.test.ts +259 -2
- package/src/notifications/adapters/platform.ts +14 -0
- package/src/notifications/broadcaster.ts +28 -9
- package/src/notifications/chat-reply-policy.ts +11 -0
- package/src/notifications/completion-policy.ts +5 -1
- package/src/notifications/types.ts +2 -0
- package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
- package/src/persistence/conversation-auto-archive.ts +170 -0
- package/src/persistence/conversation-crud.ts +2 -1
- package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
- package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
- package/src/persistence/schema/conversations.ts +1 -0
- package/src/persistence/steps.ts +2 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
- package/src/runtime/routes/conversation-query-routes.ts +48 -10
- 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
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
196
|
-
expect(publishedTagSets[
|
|
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(
|
|
496
|
-
|
|
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
|
});
|