@vellumai/assistant 0.8.2 → 0.8.3
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 +11 -12
- package/docker-entrypoint.sh +13 -1
- package/docker-init-apt-root.sh +79 -6
- package/openapi.yaml +336 -21
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
- package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
- package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
- package/src/__tests__/config-get-vision-flag.test.ts +136 -0
- package/src/__tests__/config-loader-backfill.test.ts +115 -18
- package/src/__tests__/context-token-estimator.test.ts +30 -65
- package/src/__tests__/conversation-agent-loop.test.ts +57 -1
- package/src/__tests__/conversation-media-retry.test.ts +19 -8
- package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
- package/src/__tests__/date-context.test.ts +45 -0
- package/src/__tests__/external-plugin-loader.test.ts +91 -19
- package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
- package/src/__tests__/guardian-dispatch.test.ts +1 -0
- package/src/__tests__/heartbeat-service.test.ts +24 -164
- package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
- package/src/__tests__/host-app-control-proxy.test.ts +241 -0
- package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
- package/src/__tests__/injector-background-turn.test.ts +153 -0
- package/src/__tests__/injector-chain.test.ts +5 -0
- package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
- package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
- package/src/__tests__/llm-catalog-parity.test.ts +3 -0
- package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
- package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
- package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
- package/src/__tests__/llm-resolver.test.ts +255 -2
- package/src/__tests__/managed-profile-guard.test.ts +10 -0
- package/src/__tests__/notification-decision-fallback.test.ts +0 -91
- package/src/__tests__/notification-decision-strategy.test.ts +14 -31
- package/src/__tests__/notification-deep-link.test.ts +15 -0
- package/src/__tests__/notification-guardian-path.test.ts +1 -2
- package/src/__tests__/notification-platform-adapter.test.ts +5 -4
- package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
- package/src/__tests__/openai-provider.test.ts +218 -3
- package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
- package/src/__tests__/openrouter-provider-only.test.ts +51 -3
- package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
- package/src/__tests__/platform-proxy-context.test.ts +6 -1
- package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
- package/src/__tests__/plugin-types.test.ts +2 -2
- package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
- package/src/__tests__/system-prompt.test.ts +6 -73
- package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
- package/src/a2a/__tests__/agent-card.test.ts +98 -0
- package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
- package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
- package/src/a2a/__tests__/task-store.test.ts +246 -0
- package/src/a2a/agent-card.ts +58 -0
- package/src/a2a/feature-gate.ts +8 -0
- package/src/a2a/protocol-constants.ts +21 -0
- package/src/a2a/protocol-errors.ts +50 -0
- package/src/a2a/protocol-types.ts +162 -0
- package/src/a2a/task-store.ts +168 -0
- package/src/agent/loop.ts +167 -18
- package/src/channels/config.ts +9 -0
- package/src/channels/types.ts +14 -0
- package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
- package/src/cli/commands/__tests__/schedules.test.ts +469 -0
- package/src/cli/commands/notifications.ts +65 -35
- package/src/cli/commands/plugins.ts +67 -0
- package/src/cli/commands/schedules.ts +297 -5
- package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
- package/src/cli/lib/install-from-github.ts +8 -9
- package/src/cli/lib/search-plugins.ts +163 -0
- package/src/cli/program.ts +14 -0
- package/src/config/assistant-feature-flags.ts +24 -54
- package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
- package/src/config/call-site-defaults.ts +105 -0
- package/src/config/feature-flag-registry.json +21 -29
- package/src/config/llm-resolver.ts +52 -1
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
- package/src/config/schemas/channels.ts +9 -0
- package/src/config/schemas/conversations.ts +10 -0
- package/src/config/schemas/heartbeat.ts +14 -0
- package/src/config/schemas/llm.ts +1 -3
- package/src/config/schemas/memory-retrospective.ts +1 -1
- package/src/config/schemas/memory-v2.ts +4 -4
- package/src/config/schemas/memory.ts +3 -1
- package/src/config/seed-inference-profiles.ts +99 -29
- package/src/context/compactor.ts +72 -12
- package/src/context/token-estimator.ts +32 -34
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
- package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
- package/src/daemon/conversation-agent-loop.ts +29 -2
- package/src/daemon/conversation-runtime-assembly.ts +9 -0
- package/src/daemon/conversation.ts +0 -7
- package/src/daemon/date-context.ts +40 -0
- package/src/daemon/guardian-action-generators.ts +1 -125
- package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
- package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
- package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
- package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
- package/src/daemon/handlers/config-a2a.ts +289 -0
- package/src/daemon/handlers/conversations.ts +1 -0
- package/src/daemon/host-app-control-proxy.ts +69 -18
- package/src/daemon/host-proxy-preactivation.ts +85 -18
- package/src/daemon/lifecycle.ts +49 -61
- package/src/daemon/memory-v2-startup.ts +49 -13
- package/src/daemon/message-types/notifications.ts +21 -0
- package/src/daemon/pkb-reminder-builder.test.ts +10 -53
- package/src/daemon/pkb-reminder-builder.ts +4 -19
- package/src/daemon/process-message.ts +3 -0
- package/src/daemon/skill-memory-refresh.ts +5 -1
- package/src/daemon/wake-target-adapter.ts +2 -0
- package/src/export/__tests__/transcript-formatter.test.ts +121 -0
- package/src/export/transcript-formatter.ts +54 -20
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
- package/src/heartbeat/heartbeat-service.ts +34 -191
- package/src/home/__tests__/feed-types.test.ts +40 -0
- package/src/home/feed-types.ts +14 -2
- package/src/ipc/cli-client.ts +147 -45
- package/src/memory/__tests__/conversation-queries.test.ts +220 -0
- package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
- package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
- package/src/memory/conversation-queries.ts +87 -1
- package/src/memory/conversation-title-service.ts +26 -4
- package/src/memory/db-init.ts +6 -0
- package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
- package/src/memory/graph/conversation-graph-memory.ts +18 -6
- package/src/memory/graph/tools.ts +6 -37
- package/src/memory/invite-store.ts +53 -0
- package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
- package/src/memory/llm-request-log-store.ts +92 -1
- package/src/memory/memory-retrospective-enqueue.ts +1 -20
- package/src/memory/memory-retrospective-job.ts +33 -6
- package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
- package/src/memory/migrations/251-a2a-tasks.ts +49 -0
- package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/a2a.ts +15 -0
- package/src/memory/schema/index.ts +1 -0
- package/src/memory/schema/inference.ts +2 -0
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
- package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
- package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
- package/src/memory/v2/__tests__/injection.test.ts +190 -3
- package/src/memory/v2/__tests__/static-context.test.ts +12 -1
- package/src/memory/v2/activation-store.ts +14 -16
- package/src/memory/v2/cli-command-content.ts +19 -0
- package/src/memory/v2/cli-command-store.ts +304 -0
- package/src/memory/v2/frontmatter-sweep.ts +7 -1
- package/src/memory/v2/injection.ts +49 -20
- package/src/memory/v2/page-index.ts +38 -13
- package/src/memory/v2/static-context.ts +4 -4
- package/src/memory/v2/types.ts +23 -0
- package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
- package/src/messaging/providers/a2a/deliver.ts +156 -0
- package/src/messaging/providers/gmail/client.ts +9 -2
- package/src/messaging/providers/index.ts +11 -2
- package/src/notifications/__tests__/broadcaster.test.ts +203 -0
- package/src/notifications/__tests__/decision-engine.test.ts +283 -0
- package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
- package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
- package/src/notifications/adapters/macos.ts +12 -2
- package/src/notifications/broadcaster.ts +29 -4
- package/src/notifications/copy-composer.ts +17 -64
- package/src/notifications/decision-engine.ts +111 -44
- package/src/notifications/deterministic-checks.ts +96 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/home-feed-side-effect.ts +85 -6
- package/src/notifications/signal.ts +0 -4
- package/src/notifications/types.ts +8 -0
- package/src/oauth/platform-connection.test.ts +43 -3
- package/src/oauth/platform-connection.ts +13 -4
- package/src/plugins/defaults/injectors.ts +38 -19
- package/src/plugins/external-plugin-loader.ts +82 -10
- package/src/plugins/types.ts +16 -7
- package/src/prompts/__tests__/system-prompt.test.ts +6 -51
- package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
- package/src/prompts/system-prompt.ts +0 -8
- package/src/prompts/templates/BOOTSTRAP.md +5 -5
- package/src/prompts/templates/system-sections.ts +0 -9
- package/src/providers/__tests__/inference.test.ts +2 -0
- package/src/providers/call-site-routing.ts +24 -6
- package/src/providers/connection-resolution.ts +63 -13
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
- package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
- package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
- package/src/providers/inference/adapter-factory.ts +9 -20
- package/src/providers/inference/auth.ts +12 -0
- package/src/providers/inference/backfill.ts +14 -1
- package/src/providers/inference/connections.ts +85 -5
- package/src/providers/inference/resolve-auth.ts +2 -0
- package/src/providers/model-catalog.ts +199 -244
- package/src/providers/model-intents.ts +3 -3
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
- package/src/providers/openai/chat-completions-provider.ts +159 -6
- package/src/providers/openrouter/client.ts +42 -4
- package/src/providers/platform-proxy/constants.ts +3 -4
- package/src/providers/provider-catalog-visibility.ts +3 -1
- package/src/providers/provider-send-message.ts +27 -12
- package/src/providers/registry.ts +30 -1
- package/src/runtime/agent-wake.ts +61 -1
- package/src/runtime/auth/route-policy.ts +13 -0
- package/src/runtime/http-server.ts +7 -16
- package/src/runtime/http-types.ts +0 -47
- package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
- package/src/runtime/routes/channel-availability-routes.ts +5 -0
- package/src/runtime/routes/consolidation-routes.ts +100 -0
- package/src/runtime/routes/conversation-query-routes.ts +70 -11
- package/src/runtime/routes/conversation-routes.ts +7 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
- package/src/runtime/routes/integrations/a2a.ts +235 -0
- package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
- package/src/runtime/routes/subagents-routes.ts +41 -0
- package/src/subagent/manager.ts +2 -0
- package/src/tools/memory/register.ts +1 -9
- package/src/tools/registry.ts +2 -2
- package/src/tools/types.ts +37 -2
- package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
- package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
- package/src/runtime/guardian-action-conversation-turn.ts +0 -99
|
@@ -83,9 +83,11 @@ beforeEach(() => {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
describe("writeHomeFeedItemForSignal", () => {
|
|
86
|
-
test("background conversation signal writes a feed item with rendered
|
|
86
|
+
test("background conversation signal writes a feed item with payload title + rendered body", async () => {
|
|
87
87
|
conversationRow = { conversationType: "background" };
|
|
88
|
-
const signal = makeSignal(
|
|
88
|
+
const signal = makeSignal({
|
|
89
|
+
contextPayload: { title: "Background job done" },
|
|
90
|
+
});
|
|
89
91
|
const decision = makeDecision({
|
|
90
92
|
renderedCopy: {
|
|
91
93
|
vellum: {
|
|
@@ -146,8 +148,13 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
146
148
|
visibleInSourceNow: false,
|
|
147
149
|
},
|
|
148
150
|
});
|
|
151
|
+
const decision = makeDecision({
|
|
152
|
+
renderedCopy: {
|
|
153
|
+
vellum: { title: "Async title", body: "Async body" },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
149
156
|
|
|
150
|
-
const item = await writeHomeFeedItemForSignal(signal,
|
|
157
|
+
const item = await writeHomeFeedItemForSignal(signal, decision, []);
|
|
151
158
|
|
|
152
159
|
expect(item).not.toBeNull();
|
|
153
160
|
expect(appendCalls).toHaveLength(1);
|
|
@@ -156,9 +163,49 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
156
163
|
expect(conversationLookups).toHaveLength(0);
|
|
157
164
|
});
|
|
158
165
|
|
|
166
|
+
test("assistant_tool source mirrors to the home feed even without a background conversation or async hint", async () => {
|
|
167
|
+
// Regression: the `notifications send` CLI/skill emits with
|
|
168
|
+
// `sourceChannel: "assistant_tool"`, a synthetic `cli-<ts>` source
|
|
169
|
+
// context id that does not resolve to a conversation, and
|
|
170
|
+
// `isAsyncBackground: false`. Before the fix, `shouldMirrorToHomeFeed`
|
|
171
|
+
// returned `false` for this shape and the Inbox stayed empty.
|
|
172
|
+
conversationRow = null;
|
|
173
|
+
const signal = makeSignal({
|
|
174
|
+
sourceChannel: "assistant_tool",
|
|
175
|
+
sourceEventName: "assistant.share",
|
|
176
|
+
sourceContextId: "cli-12345",
|
|
177
|
+
contextPayload: { title: "Shared from CLI" },
|
|
178
|
+
attentionHints: {
|
|
179
|
+
requiresAction: false,
|
|
180
|
+
urgency: "low",
|
|
181
|
+
isAsyncBackground: false,
|
|
182
|
+
visibleInSourceNow: false,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const decision = makeDecision({
|
|
186
|
+
renderedCopy: {
|
|
187
|
+
vellum: { title: "Shared from CLI", body: "Body from CLI share" },
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const item = await writeHomeFeedItemForSignal(signal, decision, []);
|
|
192
|
+
|
|
193
|
+
expect(item).not.toBeNull();
|
|
194
|
+
expect(appendCalls).toHaveLength(1);
|
|
195
|
+
expect(appendCalls[0]!.title).toBe("Shared from CLI");
|
|
196
|
+
expect(appendCalls[0]!.noteworthy).toBe(true);
|
|
197
|
+
// The assistant_tool short-circuit must not consult the conversation store.
|
|
198
|
+
expect(conversationLookups).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
159
201
|
test("vellum delivery result conversationId propagates onto the feed item", async () => {
|
|
160
202
|
conversationRow = { conversationType: "background" };
|
|
161
203
|
const signal = makeSignal();
|
|
204
|
+
const decision = makeDecision({
|
|
205
|
+
renderedCopy: {
|
|
206
|
+
vellum: { title: "Routed title", body: "Routed body" },
|
|
207
|
+
},
|
|
208
|
+
});
|
|
162
209
|
const deliveryResults: NotificationDeliveryResult[] = [
|
|
163
210
|
{
|
|
164
211
|
channel: "telegram",
|
|
@@ -176,7 +223,7 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
176
223
|
|
|
177
224
|
const item = await writeHomeFeedItemForSignal(
|
|
178
225
|
signal,
|
|
179
|
-
|
|
226
|
+
decision,
|
|
180
227
|
deliveryResults,
|
|
181
228
|
);
|
|
182
229
|
|
|
@@ -184,7 +231,7 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
184
231
|
expect(appendCalls[0]!.conversationId).toBe("conv-vellum-1");
|
|
185
232
|
});
|
|
186
233
|
|
|
187
|
-
test("
|
|
234
|
+
test("returns null and does not write when no rendered copy or payload title/body is present", async () => {
|
|
188
235
|
conversationRow = { conversationType: "scheduled" };
|
|
189
236
|
const signal = makeSignal({
|
|
190
237
|
sourceEventName: "watcher.notification",
|
|
@@ -193,7 +240,383 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
193
240
|
|
|
194
241
|
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
195
242
|
|
|
196
|
-
expect(item
|
|
197
|
-
expect(
|
|
243
|
+
expect(item).toBeNull();
|
|
244
|
+
expect(appendCalls).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("returns null when only the title is available but the summary would fall back to event name", async () => {
|
|
248
|
+
conversationRow = { conversationType: "background" };
|
|
249
|
+
const signal = makeSignal({
|
|
250
|
+
sourceEventName: "example.event",
|
|
251
|
+
contextPayload: { title: "Real title" },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
255
|
+
|
|
256
|
+
expect(item).toBeNull();
|
|
257
|
+
expect(appendCalls).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("writes a feed item with undefined title when only the body is available", async () => {
|
|
261
|
+
// Regression: when `notifications send` is called without `--title`, the
|
|
262
|
+
// notification pipeline must not manufacture a title (the LLM's rendered
|
|
263
|
+
// copy echoes the body into `renderedCopy.title`). Leave `title`
|
|
264
|
+
// undefined so renderers fall back to `summary` instead of stuttering.
|
|
265
|
+
conversationRow = { conversationType: "background" };
|
|
266
|
+
const signal = makeSignal({
|
|
267
|
+
sourceEventName: "example.event",
|
|
268
|
+
contextPayload: { body: "Real body" },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
272
|
+
|
|
273
|
+
expect(item).not.toBeNull();
|
|
274
|
+
expect(appendCalls).toHaveLength(1);
|
|
275
|
+
expect(appendCalls[0]!.title).toBeUndefined();
|
|
276
|
+
expect(appendCalls[0]!.summary).toBe("Real body");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("ignores LLM-rendered title when no payload title was supplied", async () => {
|
|
280
|
+
// The LLM often echoes the body verbatim into `renderedCopy.title` when
|
|
281
|
+
// the source didn't pass one. The home-feed writer must NOT promote that
|
|
282
|
+
// echo into the feed item — only an explicit source title is honored.
|
|
283
|
+
conversationRow = { conversationType: "background" };
|
|
284
|
+
const signal = makeSignal({
|
|
285
|
+
sourceEventName: "example.event",
|
|
286
|
+
contextPayload: { body: "Real body" },
|
|
287
|
+
});
|
|
288
|
+
const decision = makeDecision({
|
|
289
|
+
renderedCopy: {
|
|
290
|
+
vellum: {
|
|
291
|
+
title: "Real body",
|
|
292
|
+
body: "Real body",
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const item = await writeHomeFeedItemForSignal(signal, decision, []);
|
|
298
|
+
|
|
299
|
+
expect(item).not.toBeNull();
|
|
300
|
+
expect(appendCalls).toHaveLength(1);
|
|
301
|
+
expect(appendCalls[0]!.title).toBeUndefined();
|
|
302
|
+
expect(appendCalls[0]!.summary).toBe("Real body");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("treats whitespace-only rendered copy and payload values as missing and returns null", async () => {
|
|
306
|
+
conversationRow = { conversationType: "background" };
|
|
307
|
+
const signal = makeSignal({
|
|
308
|
+
sourceEventName: "example.event",
|
|
309
|
+
contextPayload: { title: " ", body: "\t\n" },
|
|
310
|
+
});
|
|
311
|
+
const decision = makeDecision({
|
|
312
|
+
renderedCopy: {
|
|
313
|
+
vellum: { title: " ", body: " " },
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const item = await writeHomeFeedItemForSignal(signal, decision, []);
|
|
318
|
+
|
|
319
|
+
expect(item).toBeNull();
|
|
320
|
+
expect(appendCalls).toHaveLength(0);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("falls back to a non-vellum channel's rendered copy when vellum copy is absent", async () => {
|
|
324
|
+
// Regression: when `preferredChannels` narrows an assistant_tool signal
|
|
325
|
+
// to a non-vellum channel (e.g. telegram), the broadcaster ships real
|
|
326
|
+
// copy on that channel but `renderedCopy.vellum` is undefined. The
|
|
327
|
+
// guard must still write to the home feed using the first available
|
|
328
|
+
// rendered copy entry rather than skipping silently.
|
|
329
|
+
conversationRow = { conversationType: "background" };
|
|
330
|
+
const signal = makeSignal({
|
|
331
|
+
sourceChannel: "assistant_tool",
|
|
332
|
+
sourceEventName: "assistant.share",
|
|
333
|
+
sourceContextId: "cli-12345",
|
|
334
|
+
contextPayload: { title: "Telegram title" },
|
|
335
|
+
});
|
|
336
|
+
const decision = makeDecision({
|
|
337
|
+
selectedChannels: ["telegram"],
|
|
338
|
+
renderedCopy: {
|
|
339
|
+
telegram: { title: "Telegram title", body: "Telegram body" },
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const item = await writeHomeFeedItemForSignal(signal, decision, []);
|
|
344
|
+
|
|
345
|
+
expect(item).not.toBeNull();
|
|
346
|
+
expect(appendCalls).toHaveLength(1);
|
|
347
|
+
expect(appendCalls[0]!.title).toBe("Telegram title");
|
|
348
|
+
expect(appendCalls[0]!.summary).toBe("Telegram body");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("ignores rendered copy for channels not in selectedChannels", async () => {
|
|
352
|
+
// Regression: routing-intent enforcement can prune selectedChannels
|
|
353
|
+
// without pruning renderedCopy, leaving copy entries for channels that
|
|
354
|
+
// were never delivered. The fallback must only consider channels that
|
|
355
|
+
// actually shipped — otherwise an unselected channel's copy can land in
|
|
356
|
+
// Home in place of the selected channel's copy.
|
|
357
|
+
conversationRow = { conversationType: "background" };
|
|
358
|
+
const signal = makeSignal({
|
|
359
|
+
sourceChannel: "assistant_tool",
|
|
360
|
+
sourceEventName: "assistant.share",
|
|
361
|
+
sourceContextId: "cli-12345",
|
|
362
|
+
contextPayload: { title: "Telegram title" },
|
|
363
|
+
});
|
|
364
|
+
const decision = makeDecision({
|
|
365
|
+
selectedChannels: ["telegram"],
|
|
366
|
+
renderedCopy: {
|
|
367
|
+
slack: {
|
|
368
|
+
title: "Slack title (unselected)",
|
|
369
|
+
body: "Slack body (unselected)",
|
|
370
|
+
},
|
|
371
|
+
telegram: { title: "Telegram title", body: "Telegram body" },
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const item = await writeHomeFeedItemForSignal(signal, decision, []);
|
|
376
|
+
|
|
377
|
+
expect(item).not.toBeNull();
|
|
378
|
+
expect(appendCalls).toHaveLength(1);
|
|
379
|
+
expect(appendCalls[0]!.title).toBe("Telegram title");
|
|
380
|
+
expect(appendCalls[0]!.summary).toBe("Telegram body");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("skips fallback when only unselected channels have rendered copy", async () => {
|
|
384
|
+
// Regression: if every renderedCopy entry is for a channel that was
|
|
385
|
+
// pruned from selectedChannels, treat it as no copy at all rather than
|
|
386
|
+
// surfacing the stale entry.
|
|
387
|
+
conversationRow = { conversationType: "background" };
|
|
388
|
+
const signal = makeSignal({
|
|
389
|
+
sourceChannel: "assistant_tool",
|
|
390
|
+
sourceEventName: "assistant.share",
|
|
391
|
+
sourceContextId: "cli-12345",
|
|
392
|
+
});
|
|
393
|
+
const decision = makeDecision({
|
|
394
|
+
selectedChannels: ["telegram"],
|
|
395
|
+
renderedCopy: {
|
|
396
|
+
slack: {
|
|
397
|
+
title: "Slack title (unselected)",
|
|
398
|
+
body: "Slack body (unselected)",
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const item = await writeHomeFeedItemForSignal(signal, decision, []);
|
|
404
|
+
|
|
405
|
+
expect(item).toBeNull();
|
|
406
|
+
expect(appendCalls).toHaveLength(0);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("falls back to requestedTitle/requestedMessage payload keys", async () => {
|
|
410
|
+
// Regression: the `notifications send` CLI surface stores the
|
|
411
|
+
// user-supplied copy on the signal payload under `requestedTitle` and
|
|
412
|
+
// `requestedMessage`. If the decision strips renderedCopy.vellum (e.g.
|
|
413
|
+
// routed only to a non-vellum channel that also lacks renderedCopy),
|
|
414
|
+
// the home-feed guard must still recover the copy from the payload.
|
|
415
|
+
conversationRow = { conversationType: "background" };
|
|
416
|
+
const signal = makeSignal({
|
|
417
|
+
sourceChannel: "assistant_tool",
|
|
418
|
+
sourceEventName: "assistant.share",
|
|
419
|
+
sourceContextId: "cli-12345",
|
|
420
|
+
contextPayload: {
|
|
421
|
+
requestedTitle: "Requested title",
|
|
422
|
+
requestedMessage: "Requested message body",
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
427
|
+
|
|
428
|
+
expect(item).not.toBeNull();
|
|
429
|
+
expect(appendCalls).toHaveLength(1);
|
|
430
|
+
expect(appendCalls[0]!.title).toBe("Requested title");
|
|
431
|
+
expect(appendCalls[0]!.summary).toBe("Requested message body");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("uses payload title/body when rendered copy is absent", async () => {
|
|
435
|
+
conversationRow = { conversationType: "background" };
|
|
436
|
+
const signal = makeSignal({
|
|
437
|
+
sourceEventName: "watcher.notification",
|
|
438
|
+
contextPayload: { title: "Payload title", body: "Payload body" },
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
442
|
+
|
|
443
|
+
expect(item).not.toBeNull();
|
|
444
|
+
expect(item?.title).toBe("Payload title");
|
|
445
|
+
expect(item?.summary).toBe("Payload body");
|
|
446
|
+
expect(appendCalls).toHaveLength(1);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// ── noteworthy derivation ────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
test("assistant_tool source marks the feed item noteworthy", async () => {
|
|
452
|
+
conversationRow = { conversationType: "background" };
|
|
453
|
+
const signal = makeSignal({
|
|
454
|
+
sourceChannel: "assistant_tool",
|
|
455
|
+
sourceEventName: "user.send_notification",
|
|
456
|
+
contextPayload: { title: "Tool share", body: "Body" },
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
460
|
+
|
|
461
|
+
expect(item?.noteworthy).toBe(true);
|
|
462
|
+
expect(appendCalls[0]!.noteworthy).toBe(true);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("assistant_tool source sets fromAssistant=true", async () => {
|
|
466
|
+
conversationRow = { conversationType: "background" };
|
|
467
|
+
const signal = makeSignal({
|
|
468
|
+
sourceChannel: "assistant_tool",
|
|
469
|
+
sourceEventName: "user.send_notification",
|
|
470
|
+
contextPayload: { title: "Tool share", body: "Body" },
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
474
|
+
|
|
475
|
+
expect(item?.fromAssistant).toBe(true);
|
|
476
|
+
expect(appendCalls[0]!.fromAssistant).toBe(true);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("non-assistant_tool source sets fromAssistant=false", async () => {
|
|
480
|
+
conversationRow = { conversationType: "background" };
|
|
481
|
+
const signal = makeSignal({
|
|
482
|
+
sourceChannel: "scheduler",
|
|
483
|
+
sourceEventName: "schedule.notify",
|
|
484
|
+
contextPayload: { title: "Reminder", body: "Time to do thing" },
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
488
|
+
|
|
489
|
+
expect(item?.fromAssistant).toBe(false);
|
|
490
|
+
expect(appendCalls[0]!.fromAssistant).toBe(false);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("scheduler source with schedule.notify is not noteworthy", async () => {
|
|
494
|
+
conversationRow = { conversationType: "background" };
|
|
495
|
+
const signal = makeSignal({
|
|
496
|
+
sourceChannel: "scheduler",
|
|
497
|
+
sourceEventName: "schedule.notify",
|
|
498
|
+
contextPayload: { title: "Reminder", body: "Time to do thing" },
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
502
|
+
|
|
503
|
+
expect(item?.noteworthy).toBe(false);
|
|
504
|
+
expect(appendCalls[0]!.noteworthy).toBe(false);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("assistant_tool source with guardian.question event still wins (noteworthy true)", async () => {
|
|
508
|
+
conversationRow = { conversationType: "background" };
|
|
509
|
+
const signal = makeSignal({
|
|
510
|
+
sourceChannel: "assistant_tool",
|
|
511
|
+
sourceEventName: "guardian.question",
|
|
512
|
+
contextPayload: { title: "Question", body: "Approve?" },
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
516
|
+
|
|
517
|
+
expect(item?.noteworthy).toBe(true);
|
|
518
|
+
expect(appendCalls[0]!.noteworthy).toBe(true);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("activity.failed with critical urgency is noteworthy (scheduler source)", async () => {
|
|
522
|
+
conversationRow = { conversationType: "background" };
|
|
523
|
+
const signal = makeSignal({
|
|
524
|
+
sourceChannel: "scheduler",
|
|
525
|
+
sourceEventName: "activity.failed",
|
|
526
|
+
contextPayload: { title: "Job failed", body: "Critical failure" },
|
|
527
|
+
attentionHints: {
|
|
528
|
+
requiresAction: false,
|
|
529
|
+
urgency: "critical",
|
|
530
|
+
isAsyncBackground: false,
|
|
531
|
+
visibleInSourceNow: false,
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
536
|
+
|
|
537
|
+
expect(item?.noteworthy).toBe(true);
|
|
538
|
+
expect(appendCalls[0]!.noteworthy).toBe(true);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("activity.failed with low urgency is not noteworthy (scheduler source)", async () => {
|
|
542
|
+
conversationRow = { conversationType: "background" };
|
|
543
|
+
const signal = makeSignal({
|
|
544
|
+
sourceChannel: "scheduler",
|
|
545
|
+
sourceEventName: "activity.failed",
|
|
546
|
+
contextPayload: { title: "Job failed", body: "Routine failure" },
|
|
547
|
+
attentionHints: {
|
|
548
|
+
requiresAction: false,
|
|
549
|
+
urgency: "low",
|
|
550
|
+
isAsyncBackground: false,
|
|
551
|
+
visibleInSourceNow: false,
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
556
|
+
|
|
557
|
+
expect(item?.noteworthy).toBe(false);
|
|
558
|
+
expect(appendCalls[0]!.noteworthy).toBe(false);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("activity.failed from background-job-runner shape (assistant_tool + medium) is NOT noteworthy", async () => {
|
|
562
|
+
// Regression: `runtime/background-job-runner.ts` emits activity.failed
|
|
563
|
+
// with `sourceChannel: "assistant_tool"` and `urgency: "medium"`. Before
|
|
564
|
+
// the fix, the assistant_tool short-circuit short-circuited noteworthy
|
|
565
|
+
// to true, so every routine watcher/heartbeat failure landed in the
|
|
566
|
+
// Inbox. The activity.failed rule must run first and require critical
|
|
567
|
+
// urgency.
|
|
568
|
+
conversationRow = { conversationType: "background" };
|
|
569
|
+
const signal = makeSignal({
|
|
570
|
+
sourceChannel: "assistant_tool",
|
|
571
|
+
sourceEventName: "activity.failed",
|
|
572
|
+
contextPayload: { title: "Job failed", body: "Routine failure" },
|
|
573
|
+
attentionHints: {
|
|
574
|
+
requiresAction: false,
|
|
575
|
+
urgency: "medium",
|
|
576
|
+
isAsyncBackground: true,
|
|
577
|
+
visibleInSourceNow: false,
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
582
|
+
|
|
583
|
+
expect(item?.noteworthy).toBe(false);
|
|
584
|
+
expect(appendCalls[0]!.noteworthy).toBe(false);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("activity.failed from assistant_tool with critical urgency IS noteworthy", async () => {
|
|
588
|
+
// Companion to the regression test above: a background-job-runner
|
|
589
|
+
// shape that opts up to critical urgency should still reach the Inbox.
|
|
590
|
+
conversationRow = { conversationType: "background" };
|
|
591
|
+
const signal = makeSignal({
|
|
592
|
+
sourceChannel: "assistant_tool",
|
|
593
|
+
sourceEventName: "activity.failed",
|
|
594
|
+
contextPayload: { title: "Job failed", body: "Critical failure" },
|
|
595
|
+
attentionHints: {
|
|
596
|
+
requiresAction: false,
|
|
597
|
+
urgency: "critical",
|
|
598
|
+
isAsyncBackground: true,
|
|
599
|
+
visibleInSourceNow: false,
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
604
|
+
|
|
605
|
+
expect(item?.noteworthy).toBe(true);
|
|
606
|
+
expect(appendCalls[0]!.noteworthy).toBe(true);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("credential.health_alert is noteworthy regardless of source channel", async () => {
|
|
610
|
+
conversationRow = { conversationType: "background" };
|
|
611
|
+
const signal = makeSignal({
|
|
612
|
+
sourceChannel: "watcher",
|
|
613
|
+
sourceEventName: "credential.health_alert",
|
|
614
|
+
contextPayload: { title: "Credential expired", body: "Reconnect" },
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision(), []);
|
|
618
|
+
|
|
619
|
+
expect(item?.noteworthy).toBe(true);
|
|
620
|
+
expect(appendCalls[0]!.noteworthy).toBe(true);
|
|
198
621
|
});
|
|
199
622
|
});
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
* and mobile clients via the daemon's event broadcast mechanism.
|
|
4
4
|
*
|
|
5
5
|
* The adapter broadcasts a `notification_intent` message that the Vellum
|
|
6
|
-
* client
|
|
7
|
-
*
|
|
6
|
+
* client uses for two distinct purposes: paired-conversation bookkeeping
|
|
7
|
+
* (mark-unseen + history catch-up, fallback dedup) and posting an OS
|
|
8
|
+
* banner via `UNUserNotificationCenter`. The banner posting is gated by
|
|
9
|
+
* the `silent` flag — set to true for non-urgent (`low`/`medium`) signals
|
|
10
|
+
* so the notification center inbox still receives the entry but the OS
|
|
11
|
+
* does not surface a push banner. Urgent signals (`high`/`critical`)
|
|
12
|
+
* broadcast with `silent: false` and fire the banner.
|
|
8
13
|
*
|
|
9
14
|
* Guardian-sensitive notifications (approval requests, escalation alerts)
|
|
10
15
|
* are annotated with `targetGuardianPrincipalId` so that only clients
|
|
@@ -75,6 +80,9 @@ export class VellumAdapter implements ChannelAdapter {
|
|
|
75
80
|
? guardianPrincipalId
|
|
76
81
|
: undefined;
|
|
77
82
|
|
|
83
|
+
const silent =
|
|
84
|
+
payload.urgency !== "high" && payload.urgency !== "critical";
|
|
85
|
+
|
|
78
86
|
this.broadcast({
|
|
79
87
|
type: "notification_intent",
|
|
80
88
|
deliveryId: payload.deliveryId,
|
|
@@ -83,6 +91,7 @@ export class VellumAdapter implements ChannelAdapter {
|
|
|
83
91
|
body: payload.copy.body,
|
|
84
92
|
deepLinkMetadata: payload.deepLinkTarget,
|
|
85
93
|
targetGuardianPrincipalId,
|
|
94
|
+
silent,
|
|
86
95
|
} as ServerMessage);
|
|
87
96
|
|
|
88
97
|
log.info(
|
|
@@ -90,6 +99,7 @@ export class VellumAdapter implements ChannelAdapter {
|
|
|
90
99
|
sourceEventName: payload.sourceEventName,
|
|
91
100
|
title: payload.copy.title,
|
|
92
101
|
guardianScoped: targetGuardianPrincipalId != null,
|
|
102
|
+
silent,
|
|
93
103
|
},
|
|
94
104
|
"Vellum notification intent broadcast",
|
|
95
105
|
);
|
|
@@ -45,6 +45,11 @@ export interface ConversationCreatedInfo {
|
|
|
45
45
|
groupId?: string;
|
|
46
46
|
/** Semantic source from the signal producer (e.g. "schedule", "reminder"). */
|
|
47
47
|
source?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Mirrors the vellum adapter's `silent` flag. When true the client
|
|
50
|
+
* must skip the fallback OS banner — the sidebar entry still appears.
|
|
51
|
+
*/
|
|
52
|
+
silent: boolean;
|
|
48
53
|
}
|
|
49
54
|
export type OnConversationCreatedFn = (info: ConversationCreatedInfo) => void;
|
|
50
55
|
export interface BroadcastDecisionOptions {
|
|
@@ -145,10 +150,25 @@ export class NotificationBroadcaster {
|
|
|
145
150
|
if (!fallbackCopy) {
|
|
146
151
|
fallbackCopy = composeFallbackCopy(signal, decision.selectedChannels);
|
|
147
152
|
}
|
|
148
|
-
copy = fallbackCopy[channel]
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
copy = fallbackCopy[channel];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Fail closed: if neither the decision nor the fallback composer produced
|
|
157
|
+
// a usable body, skip the channel rather than leaking the raw event name
|
|
158
|
+
// as placeholder text. The pre-send `checkRenderedCopyQuality` only sees
|
|
159
|
+
// `decision.renderedCopy`, so this is the last guard before delivery.
|
|
160
|
+
if (!copy || !copy.body?.trim()) {
|
|
161
|
+
log.warn(
|
|
162
|
+
{ channel, signalId: signal.signalId },
|
|
163
|
+
"No usable rendered copy available -- skipping channel to avoid leaking event name",
|
|
164
|
+
);
|
|
165
|
+
results.push({
|
|
166
|
+
channel,
|
|
167
|
+
destination: destination.endpoint ?? channel,
|
|
168
|
+
status: "skipped",
|
|
169
|
+
errorMessage: `No usable rendered copy for channel: ${channel}`,
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
152
172
|
}
|
|
153
173
|
|
|
154
174
|
// For tool_grant_request signals, prefer the deterministic template seed
|
|
@@ -237,6 +257,9 @@ export class NotificationBroadcaster {
|
|
|
237
257
|
|
|
238
258
|
const conversationTitle =
|
|
239
259
|
copy.conversationTitle ?? copy.title ?? signal.sourceEventName;
|
|
260
|
+
const conversationSilent =
|
|
261
|
+
signal.attentionHints.urgency !== "high" &&
|
|
262
|
+
signal.attentionHints.urgency !== "critical";
|
|
240
263
|
const info: ConversationCreatedInfo = {
|
|
241
264
|
conversationId: pairing.conversationId,
|
|
242
265
|
title: conversationTitle,
|
|
@@ -244,6 +267,7 @@ export class NotificationBroadcaster {
|
|
|
244
267
|
targetGuardianPrincipalId,
|
|
245
268
|
groupId: signal.conversationMetadata?.groupId,
|
|
246
269
|
source: signal.conversationMetadata?.source,
|
|
270
|
+
silent: conversationSilent,
|
|
247
271
|
};
|
|
248
272
|
|
|
249
273
|
// The per-dispatch onConversationCreated callback fires whenever a vellum
|
|
@@ -291,6 +315,7 @@ export class NotificationBroadcaster {
|
|
|
291
315
|
copy,
|
|
292
316
|
deepLinkTarget,
|
|
293
317
|
contextPayload: signal.contextPayload,
|
|
318
|
+
urgency: signal.attentionHints.urgency,
|
|
294
319
|
};
|
|
295
320
|
|
|
296
321
|
// Compute conversation decision audit fields for the delivery record
|