@vellumai/assistant 0.11.10 → 0.11.11-staging.1
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/AGENTS.md +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/openapi.yaml +152 -55
- package/package.json +1 -1
- package/src/__tests__/anthropic-provider.test.ts +60 -0
- package/src/__tests__/config-get-vision-flag.test.ts +36 -0
- package/src/__tests__/conversation-error.test.ts +38 -0
- package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
- package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
- package/src/__tests__/headless-browser-mode.test.ts +63 -13
- package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
- package/src/__tests__/heartbeat-service.test.ts +118 -27
- package/src/__tests__/host-app-control-proxy.test.ts +2 -2
- package/src/__tests__/host-app-control-routes.test.ts +41 -0
- package/src/__tests__/host-bash-routes.test.ts +30 -1
- package/src/__tests__/host-browser-routes.test.ts +34 -0
- package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
- package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
- package/src/__tests__/host-proxy-base.test.ts +1 -0
- package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
- package/src/__tests__/llm-schema.test.ts +32 -0
- package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
- package/src/__tests__/memory-config-file-parity.test.ts +18 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
- package/src/__tests__/messaging-send-tool.test.ts +159 -13
- package/src/__tests__/messaging-shared.test.ts +19 -0
- package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
- package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
- package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
- package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
- package/src/__tests__/schedule-routes.test.ts +30 -1
- package/src/__tests__/scheduler-result-notification.test.ts +209 -0
- package/src/__tests__/skills-install-extract.test.ts +26 -0
- package/src/__tests__/skills-install-staging.test.ts +11 -2
- package/src/api/constants/personality-sliders.test.ts +21 -0
- package/src/api/constants/personality-sliders.ts +64 -0
- package/src/api/index.ts +8 -0
- package/src/archive/ustar-size.test.ts +32 -0
- package/src/archive/ustar-size.ts +23 -0
- package/src/background-wake/next-wake.test.ts +13 -0
- package/src/background-wake/next-wake.ts +4 -2
- package/src/calls/voice-session-bridge.ts +26 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
- package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
- package/src/cli/commands/config.help.ts +3 -1
- package/src/cli/commands/memory/worker.ts +1 -1
- package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
- package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
- package/src/cli/commands/platform/credits-format.ts +57 -0
- package/src/cli/commands/platform/index.help.ts +19 -3
- package/src/cli/commands/plugins.help.ts +4 -1
- package/src/cli/commands/plugins.ts +2 -1
- package/src/cli/lib/bundled-marketplace.json +1 -1
- package/src/cli/lib/install-from-platform.ts +8 -10
- package/src/config/bundled-skills/messaging/SKILL.md +2 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +9 -1
- package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
- package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
- package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/input-modalities.test.ts +71 -0
- package/src/config/input-modalities.ts +95 -0
- package/src/config/schemas/heartbeat.ts +3 -3
- package/src/config/schemas/llm.ts +13 -3
- package/src/config/schemas/memory-storage.test.ts +39 -0
- package/src/config/schemas/memory-storage.ts +21 -0
- package/src/context/token-estimator.ts +2 -2
- package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
- package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
- package/src/daemon/conversation-error.ts +20 -7
- package/src/daemon/embedding-reconcile.ts +64 -1
- package/src/daemon/handlers/config-embeddings.ts +31 -0
- package/src/daemon/host-app-control-proxy.ts +2 -0
- package/src/daemon/host-bash-proxy.ts +1 -0
- package/src/daemon/host-cu-proxy.ts +6 -6
- package/src/daemon/host-file-proxy.ts +6 -6
- package/src/daemon/host-proxy-base.ts +10 -9
- package/src/daemon/host-transfer-proxy.ts +21 -24
- package/src/daemon/mcp-reload-service.ts +6 -0
- package/src/daemon/providers-setup.ts +5 -26
- package/src/heartbeat/heartbeat-service.ts +72 -13
- package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
- package/src/live-voice/live-voice-photo.ts +68 -4
- package/src/live-voice/live-voice-session.ts +36 -2
- package/src/live-voice/protocol.ts +98 -1
- package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
- package/src/mcp/__tests__/reload-signal.test.ts +57 -0
- package/src/mcp/__tests__/startup.test.ts +150 -0
- package/src/mcp/reload-signal.ts +50 -0
- package/src/mcp/startup.ts +101 -0
- package/src/messaging/providers/outlook/adapter.ts +2 -13
- package/src/messaging/providers/outlook/client.ts +13 -1
- package/src/messaging/providers/outlook/types.ts +3 -0
- package/src/notifications/__tests__/decision-engine.test.ts +79 -6
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
- package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
- package/src/notifications/copy-composer.ts +33 -11
- package/src/notifications/decision-engine.ts +48 -11
- package/src/notifications/events-store.ts +68 -2
- package/src/notifications/home-feed-side-effect.ts +15 -3
- package/src/notifications/schedule-result-producer.ts +275 -0
- package/src/notifications/signal.ts +5 -0
- package/src/oauth/seed-providers.ts +3 -2
- package/src/persistence/conversation-crud.ts +38 -0
- package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
- package/src/persistence/embeddings/embedding-backend.ts +148 -11
- package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
- package/src/persistence/embeddings/embedding-openai.ts +55 -4
- package/src/persistence/embeddings/embedding-types.test.ts +24 -0
- package/src/persistence/embeddings/embedding-types.ts +25 -1
- package/src/persistence/job-utils.ts +6 -2
- package/src/plugin-api/index.ts +2 -1
- package/src/plugin-api/vision-support.test.ts +81 -0
- package/src/plugin-api/vision-support.ts +57 -10
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
- package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
- package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
- package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
- package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
- package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
- package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
- package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
- package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
- package/src/providers/anthropic/client.ts +9 -4
- package/src/providers/inline-audio-support.test.ts +34 -0
- package/src/providers/inline-audio-support.ts +27 -0
- package/src/providers/model-catalog.ts +22 -2
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
- package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
- package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
- package/src/providers/openai/api-error-normalization.ts +18 -3
- package/src/providers/openai/chat-completions-provider.ts +113 -13
- package/src/providers/openai/responses-provider.ts +10 -8
- package/src/providers/provider-secret-catalog.ts +11 -1
- package/src/providers/vellum/client.ts +14 -1
- package/src/providers/vellum/personality-directions.test.ts +163 -0
- package/src/providers/vellum/personality-directions.ts +120 -0
- package/src/providers/xml-tool-call-salvage.ts +212 -0
- package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
- package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
- package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
- package/src/runtime/auth/same-actor.ts +29 -5
- package/src/runtime/background-job-runner.ts +38 -29
- package/src/runtime/guardian-action-service.ts +20 -1
- package/src/runtime/migrations/vbundle-validator.ts +3 -3
- package/src/runtime/pending-interactions.ts +4 -5
- package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
- package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
- package/src/runtime/routes/conversation-query-routes.ts +53 -11
- package/src/runtime/routes/document-comments-routes.ts +8 -2
- package/src/runtime/routes/guardian-action-routes.ts +7 -0
- package/src/runtime/routes/heartbeat-routes.ts +41 -53
- package/src/runtime/routes/host-app-control-routes.ts +10 -36
- package/src/runtime/routes/host-bash-routes.ts +11 -45
- package/src/runtime/routes/host-browser-routes.ts +24 -0
- package/src/runtime/routes/host-cu-routes.ts +11 -45
- package/src/runtime/routes/host-file-routes.ts +11 -44
- package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
- package/src/runtime/routes/host-transfer-routes.ts +33 -25
- package/src/runtime/routes/index.ts +0 -2
- package/src/runtime/routes/integrations/a2a.ts +12 -3
- package/src/runtime/routes/integrations/vercel.ts +13 -3
- package/src/runtime/routes/platform-routes.ts +121 -24
- package/src/runtime/routes/schedule-routes.ts +15 -1
- package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
- package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
- package/src/schedule/schedule-timezone.ts +25 -0
- package/src/schedule/scheduler.ts +19 -0
- package/src/schedule/worker.ts +119 -2
- package/src/skills/catalog-install.ts +40 -5
- package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
- package/src/tools/browser/browser-execution.ts +117 -53
- package/src/tools/browser/browser-status-constants.ts +6 -6
- package/src/tools/capability-offer.test.ts +87 -0
- package/src/tools/capability-offer.ts +93 -0
- package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
- package/src/tools/terminal/safe-env.ts +3 -2
- package/src/util/provider-error-patterns.ts +34 -0
- package/src/watcher/__tests__/engine.test.ts +5 -2
- package/src/watcher/engine.ts +7 -4
- package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
- package/src/runtime/routes/sight-frame-routes.ts +0 -136
|
@@ -570,14 +570,15 @@ describe("decision tool title field specification", () => {
|
|
|
570
570
|
tool.input_schema.properties.renderedCopy.properties.vellum.properties
|
|
571
571
|
.title.description;
|
|
572
572
|
|
|
573
|
-
expect(description).toContain("2 to
|
|
573
|
+
expect(description).toContain("2 to 6 words");
|
|
574
574
|
expect(description).toContain("40 characters");
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
);
|
|
575
|
+
// The title carries a bell row on its own, so it states the outcome
|
|
576
|
+
// rather than naming a topic the body then explains.
|
|
577
|
+
expect(description).toContain("WHAT HAPPENED or WHAT IS NEEDED");
|
|
578
|
+
expect(description).toContain("Lead with the outcome");
|
|
579
|
+
expect(description).toContain("Do NOT restate the body word for word");
|
|
579
580
|
expect(description).toContain("no markdown");
|
|
580
|
-
expect(description).toContain("Missing
|
|
581
|
+
expect(description).toContain("Missing context");
|
|
581
582
|
expect(description.match(/NOT '/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
|
|
582
583
|
});
|
|
583
584
|
});
|
|
@@ -663,3 +664,75 @@ describe("title normalization", () => {
|
|
|
663
664
|
expect(decision.renderedCopy.vellum?.title).toBe("Backup finished.");
|
|
664
665
|
});
|
|
665
666
|
});
|
|
667
|
+
|
|
668
|
+
function makeScheduleResultSignal(
|
|
669
|
+
overrides?: Partial<NotificationSignal>,
|
|
670
|
+
): NotificationSignal {
|
|
671
|
+
return {
|
|
672
|
+
signalId: "sig-schedule-result-test-1",
|
|
673
|
+
createdAt: Date.now(),
|
|
674
|
+
sourceChannel: "scheduler",
|
|
675
|
+
sourceContextId: "conv-schedule-run",
|
|
676
|
+
sourceEventName: "schedule.result",
|
|
677
|
+
contextPayload: {
|
|
678
|
+
requestedMessage: "- **3 new emails**\n- `deploy.sh` failed overnight",
|
|
679
|
+
requestedTitle: "Morning briefing",
|
|
680
|
+
scheduleName: "Morning briefing",
|
|
681
|
+
scheduleId: "sched-1",
|
|
682
|
+
},
|
|
683
|
+
attentionHints: {
|
|
684
|
+
requiresAction: false,
|
|
685
|
+
urgency: "medium",
|
|
686
|
+
isAsyncBackground: true,
|
|
687
|
+
visibleInSourceNow: false,
|
|
688
|
+
},
|
|
689
|
+
...overrides,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
describe("schedule.result pass-through in notification decision engine", () => {
|
|
694
|
+
beforeEach(() => {
|
|
695
|
+
persistedDecisions = [];
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("carries the run's own output verbatim rather than rewriting it", async () => {
|
|
699
|
+
// The whole value of this notification is the digest itself. The
|
|
700
|
+
// classifier compresses a signal into a short alert, which would throw
|
|
701
|
+
// away the content the user set the schedule up to receive.
|
|
702
|
+
const decision = await evaluateSignal(makeScheduleResultSignal(), [
|
|
703
|
+
"vellum",
|
|
704
|
+
"platform",
|
|
705
|
+
] as NotificationChannel[]);
|
|
706
|
+
|
|
707
|
+
expect(decision.renderedCopy.vellum?.body).toBe(
|
|
708
|
+
"- **3 new emails**\n- `deploy.sh` failed overnight",
|
|
709
|
+
);
|
|
710
|
+
expect(decision.renderedCopy.vellum?.title).toBe("Morning briefing");
|
|
711
|
+
expect(decision.reasoningSummary).toBe("schedule_result pass-through");
|
|
712
|
+
expect(decision.verbatimCopy).toBe(true);
|
|
713
|
+
expect(decision.fallbackUsed).toBe(false);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test("delivers to the notification center as well as push", async () => {
|
|
717
|
+
// Unlike an unseen chat reply, a scheduled run's output has no
|
|
718
|
+
// conversation the user is already in. `vellum` is where it persists.
|
|
719
|
+
const decision = await evaluateSignal(makeScheduleResultSignal(), [
|
|
720
|
+
"vellum",
|
|
721
|
+
"telegram",
|
|
722
|
+
"platform",
|
|
723
|
+
] as NotificationChannel[]);
|
|
724
|
+
|
|
725
|
+
expect(decision.selectedChannels).toEqual(["vellum", "platform"]);
|
|
726
|
+
expect(decision.shouldNotify).toBe(true);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("still reaches the inbox when push is unavailable", async () => {
|
|
730
|
+
const decision = await evaluateSignal(makeScheduleResultSignal(), [
|
|
731
|
+
"vellum",
|
|
732
|
+
"telegram",
|
|
733
|
+
] as NotificationChannel[]);
|
|
734
|
+
|
|
735
|
+
expect(decision.selectedChannels).toEqual(["vellum"]);
|
|
736
|
+
expect(decision.shouldNotify).toBe(true);
|
|
737
|
+
});
|
|
738
|
+
});
|
|
@@ -919,6 +919,33 @@ describe("writeHomeFeedItemForSignal", () => {
|
|
|
919
919
|
expect(appendCalls[0]!.noteworthy).toBe(true);
|
|
920
920
|
});
|
|
921
921
|
|
|
922
|
+
test("a mapped source event carries its feed category", async () => {
|
|
923
|
+
conversationRow = { conversationType: "background" };
|
|
924
|
+
const signal = makeSignal({
|
|
925
|
+
contextPayload: { title: "Nightly briefing", body: "Three things." },
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision());
|
|
929
|
+
|
|
930
|
+
expect(item?.category).toBe("scheduling");
|
|
931
|
+
expect(appendCalls[0]!.category).toBe("scheduling");
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test("an unmapped source event carries no category instead of falling back to system", async () => {
|
|
935
|
+
conversationRow = { conversationType: "background" };
|
|
936
|
+
const signal = makeSignal({
|
|
937
|
+
sourceChannel: "assistant_tool",
|
|
938
|
+
sourceEventName: "user.send_notification",
|
|
939
|
+
contextPayload: { title: "Tool share", body: "Body" },
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const item = await writeHomeFeedItemForSignal(signal, makeDecision());
|
|
943
|
+
|
|
944
|
+
expect(item).not.toBeNull();
|
|
945
|
+
expect("category" in item!).toBe(false);
|
|
946
|
+
expect("category" in appendCalls[0]!).toBe(false);
|
|
947
|
+
});
|
|
948
|
+
|
|
922
949
|
test("credential.health_alert is noteworthy regardless of source channel", async () => {
|
|
923
950
|
conversationRow = { conversationType: "background" };
|
|
924
951
|
const signal = makeSignal({
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `schedule-result-producer.ts`: which finished schedule runs owe
|
|
3
|
+
* the user a notification, and the shape of the signal the qualifying ones
|
|
4
|
+
* emit.
|
|
5
|
+
*
|
|
6
|
+
* The two gates carry the design: a run that notified itself must stay silent
|
|
7
|
+
* (no duplicates for a well-authored schedule), and a run that said nothing
|
|
8
|
+
* must stay silent (no "your schedule ran" noise).
|
|
9
|
+
*/
|
|
10
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { setOverridesForTesting } from "../../__tests__/feature-flag-test-helpers.js";
|
|
13
|
+
import type { AttentionState } from "../../persistence/conversation-attention-store.js";
|
|
14
|
+
import type { MessageRow } from "../../persistence/conversation-crud.js";
|
|
15
|
+
import type { ContentBlock } from "../../providers/types.js";
|
|
16
|
+
|
|
17
|
+
// ── Module mocks ───────────────────────────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// `mock.module` is hoisted, so these intercepts apply before the module under
|
|
20
|
+
// test resolves its imports.
|
|
21
|
+
|
|
22
|
+
const emitCalls: any[] = [];
|
|
23
|
+
let assistantRow: MessageRow | null = null;
|
|
24
|
+
/** Earlier assistant rows of the same turn (tool-call steps before the reply). */
|
|
25
|
+
let turnRows: MessageRow[] = [];
|
|
26
|
+
let attentionState: AttentionState | null = null;
|
|
27
|
+
let alreadyNotified = false;
|
|
28
|
+
const notifiedProbeArgs: Array<[string, number]> = [];
|
|
29
|
+
|
|
30
|
+
mock.module("../emit-signal.js", () => ({
|
|
31
|
+
emitNotificationSignal: async (params: any) => {
|
|
32
|
+
emitCalls.push(params);
|
|
33
|
+
return {
|
|
34
|
+
signalId: "sig-1",
|
|
35
|
+
deduplicated: false,
|
|
36
|
+
dispatched: true,
|
|
37
|
+
reason: "ok",
|
|
38
|
+
deliveryResults: [],
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
const realEventsStore = await import("../events-store.js");
|
|
44
|
+
mock.module("../events-store.js", () => ({
|
|
45
|
+
...realEventsStore,
|
|
46
|
+
hasNotifiedSourceContextSince: (contextId: string, since: number) => {
|
|
47
|
+
notifiedProbeArgs.push([contextId, since]);
|
|
48
|
+
return alreadyNotified;
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const CONVERSATION_ID = "conv-sched-1";
|
|
53
|
+
const ASSISTANT_MESSAGE_ID = "msg-assistant-1";
|
|
54
|
+
const SCHEDULE_ID = "sched-1";
|
|
55
|
+
const RUN_ID = "run-1";
|
|
56
|
+
const RUN_STARTED_AT = 1700000000000;
|
|
57
|
+
|
|
58
|
+
const realCrud = await import("../../persistence/conversation-crud.js");
|
|
59
|
+
mock.module("../../persistence/conversation-crud.js", () => ({
|
|
60
|
+
...realCrud,
|
|
61
|
+
getMessageById: (messageId: string) =>
|
|
62
|
+
messageId === ASSISTANT_MESSAGE_ID
|
|
63
|
+
? assistantRow
|
|
64
|
+
: (turnRows.find((row) => row.id === messageId) ?? null),
|
|
65
|
+
getAssistantMessageIdsInTurn: (messageId: string) =>
|
|
66
|
+
messageId === ASSISTANT_MESSAGE_ID
|
|
67
|
+
? [...turnRows.map((row) => row.id), ASSISTANT_MESSAGE_ID]
|
|
68
|
+
: [messageId],
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const realAttentionStore =
|
|
72
|
+
await import("../../persistence/conversation-attention-store.js");
|
|
73
|
+
mock.module("../../persistence/conversation-attention-store.js", () => ({
|
|
74
|
+
...realAttentionStore,
|
|
75
|
+
getAttentionStateByConversationIds: (ids: string[]) => {
|
|
76
|
+
const map = new Map<string, AttentionState>();
|
|
77
|
+
if (attentionState) {
|
|
78
|
+
map.set(ids[0], attentionState);
|
|
79
|
+
}
|
|
80
|
+
return map;
|
|
81
|
+
},
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
const { emitScheduleResultNotification } =
|
|
85
|
+
await import("../schedule-result-producer.js");
|
|
86
|
+
|
|
87
|
+
// ── Fixtures ───────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function makeAssistantRow(
|
|
90
|
+
content: ContentBlock[],
|
|
91
|
+
overrides: Partial<MessageRow> = {},
|
|
92
|
+
): MessageRow {
|
|
93
|
+
return {
|
|
94
|
+
id: ASSISTANT_MESSAGE_ID,
|
|
95
|
+
conversationId: CONVERSATION_ID,
|
|
96
|
+
role: "assistant",
|
|
97
|
+
content,
|
|
98
|
+
createdAt: RUN_STARTED_AT + 200,
|
|
99
|
+
metadata: null,
|
|
100
|
+
clientMessageId: null,
|
|
101
|
+
finalized: 1,
|
|
102
|
+
...overrides,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** An earlier row of the run's turn holding a single tool call. */
|
|
107
|
+
function makeToolCallRow(
|
|
108
|
+
name: string,
|
|
109
|
+
input: Record<string, unknown>,
|
|
110
|
+
): MessageRow {
|
|
111
|
+
return makeAssistantRow(
|
|
112
|
+
[{ type: "tool_use", id: `tu-${name}`, name, input }] as ContentBlock[],
|
|
113
|
+
{ id: `msg-${name}`, createdAt: RUN_STARTED_AT + 100 },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function makeAttentionState(
|
|
118
|
+
overrides: Partial<AttentionState> = {},
|
|
119
|
+
): AttentionState {
|
|
120
|
+
return {
|
|
121
|
+
conversationId: CONVERSATION_ID,
|
|
122
|
+
latestAssistantMessageId: ASSISTANT_MESSAGE_ID,
|
|
123
|
+
latestAssistantMessageAt: RUN_STARTED_AT + 200,
|
|
124
|
+
lastSeenAssistantMessageId: null,
|
|
125
|
+
lastSeenAssistantMessageAt: null,
|
|
126
|
+
lastSeenEventAt: null,
|
|
127
|
+
lastSeenConfidence: null,
|
|
128
|
+
lastSeenSignalType: null,
|
|
129
|
+
lastSeenSourceChannel: null,
|
|
130
|
+
lastSeenSource: null,
|
|
131
|
+
lastSeenEvidenceText: null,
|
|
132
|
+
createdAt: RUN_STARTED_AT,
|
|
133
|
+
updatedAt: RUN_STARTED_AT + 200,
|
|
134
|
+
...overrides,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const warnCalls: unknown[] = [];
|
|
139
|
+
const infoCalls: unknown[] = [];
|
|
140
|
+
const rlog = {
|
|
141
|
+
warn: (...args: unknown[]) => {
|
|
142
|
+
warnCalls.push(args);
|
|
143
|
+
},
|
|
144
|
+
info: (...args: unknown[]) => {
|
|
145
|
+
infoCalls.push(args);
|
|
146
|
+
},
|
|
147
|
+
} as any;
|
|
148
|
+
|
|
149
|
+
async function run(
|
|
150
|
+
overrides: Partial<Parameters<typeof emitScheduleResultNotification>[0]> = {},
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
await emitScheduleResultNotification({
|
|
153
|
+
scheduleId: SCHEDULE_ID,
|
|
154
|
+
scheduleName: "Morning briefing",
|
|
155
|
+
conversationId: CONVERSATION_ID,
|
|
156
|
+
runId: RUN_ID,
|
|
157
|
+
runStartedAt: RUN_STARTED_AT,
|
|
158
|
+
rlog,
|
|
159
|
+
...overrides,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
emitCalls.length = 0;
|
|
165
|
+
warnCalls.length = 0;
|
|
166
|
+
infoCalls.length = 0;
|
|
167
|
+
notifiedProbeArgs.length = 0;
|
|
168
|
+
alreadyNotified = false;
|
|
169
|
+
turnRows = [];
|
|
170
|
+
attentionState = makeAttentionState();
|
|
171
|
+
assistantRow = makeAssistantRow([
|
|
172
|
+
{ type: "text", text: "**3 new emails** and one calendar change." },
|
|
173
|
+
] as ContentBlock[]);
|
|
174
|
+
setOverridesForTesting({ "schedule-result-notify": true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("emitScheduleResultNotification", () => {
|
|
178
|
+
test("notifies when a run produced output and never notified itself", async () => {
|
|
179
|
+
await run();
|
|
180
|
+
|
|
181
|
+
expect(emitCalls).toHaveLength(1);
|
|
182
|
+
expect(emitCalls[0].sourceEventName).toBe("schedule.result");
|
|
183
|
+
expect(emitCalls[0].sourceChannel).toBe("scheduler");
|
|
184
|
+
// The run's conversation is the deep-link target and what earns the
|
|
185
|
+
// home-feed mirror.
|
|
186
|
+
expect(emitCalls[0].sourceContextId).toBe(CONVERSATION_ID);
|
|
187
|
+
expect(emitCalls[0].contextPayload.requestedTitle).toBe("Morning briefing");
|
|
188
|
+
expect(emitCalls[0].contextPayload.requestedMessage).toBe(
|
|
189
|
+
"**3 new emails** and one calendar change.",
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("stays silent when the run already notified itself", async () => {
|
|
194
|
+
alreadyNotified = true;
|
|
195
|
+
|
|
196
|
+
await run();
|
|
197
|
+
|
|
198
|
+
expect(emitCalls).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("bounds the already-notified probe to this run, not the conversation", async () => {
|
|
202
|
+
// A reused conversation carries every prior run's notifications. Without
|
|
203
|
+
// the lower bound, run #2 onward would be silenced by run #1's send.
|
|
204
|
+
await run();
|
|
205
|
+
|
|
206
|
+
expect(notifiedProbeArgs).toEqual([[CONVERSATION_ID, RUN_STARTED_AT]]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("stays silent when the run delivered by email through messaging_send", async () => {
|
|
210
|
+
// The schedule skill's prescribed route for rich content. It writes no
|
|
211
|
+
// notification event, so only the tool call shows the user has the result.
|
|
212
|
+
turnRows = [
|
|
213
|
+
makeToolCallRow("messaging_send", {
|
|
214
|
+
platform: "gmail",
|
|
215
|
+
conversation_id: "thread-1",
|
|
216
|
+
message: "Inbox digest…",
|
|
217
|
+
}),
|
|
218
|
+
];
|
|
219
|
+
assistantRow = makeAssistantRow([
|
|
220
|
+
{ type: "text", text: "Sent the digest to your inbox." },
|
|
221
|
+
] as ContentBlock[]);
|
|
222
|
+
|
|
223
|
+
await run();
|
|
224
|
+
|
|
225
|
+
expect(emitCalls).toHaveLength(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("stays silent when the run posted to Slack through chat.postMessage", async () => {
|
|
229
|
+
turnRows = [
|
|
230
|
+
makeToolCallRow("bash", {
|
|
231
|
+
command:
|
|
232
|
+
'assistant oauth request --provider slack_channel /chat.postMessage --json \'{"channel":"C1","text":"digest"}\'',
|
|
233
|
+
}),
|
|
234
|
+
];
|
|
235
|
+
assistantRow = makeAssistantRow([
|
|
236
|
+
{ type: "text", text: "Posted the digest to #general." },
|
|
237
|
+
] as ContentBlock[]);
|
|
238
|
+
|
|
239
|
+
await run();
|
|
240
|
+
|
|
241
|
+
expect(emitCalls).toHaveLength(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("still notifies when the run's tool calls were not deliveries", async () => {
|
|
245
|
+
// Bash that reads Slack is not bash that posts to it.
|
|
246
|
+
turnRows = [
|
|
247
|
+
makeToolCallRow("bash", {
|
|
248
|
+
command:
|
|
249
|
+
'assistant oauth request --provider slack_channel /conversations.history --json \'{"channel":"C1"}\'',
|
|
250
|
+
}),
|
|
251
|
+
makeToolCallRow("messaging_search", { query: "newer_than:1d" }),
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
await run();
|
|
255
|
+
|
|
256
|
+
expect(emitCalls).toHaveLength(1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("stays silent when the latest reply predates this run", async () => {
|
|
260
|
+
// A reused conversation whose current run wrote no reply: the attention
|
|
261
|
+
// state still points at yesterday's, which must not be re-sent as today's.
|
|
262
|
+
assistantRow = makeAssistantRow(
|
|
263
|
+
[{ type: "text", text: "Yesterday's briefing." }] as ContentBlock[],
|
|
264
|
+
{ createdAt: RUN_STARTED_AT - 1 },
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
await run();
|
|
268
|
+
|
|
269
|
+
expect(emitCalls).toHaveLength(0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("stays silent when the run produced no assistant message", async () => {
|
|
273
|
+
attentionState = makeAttentionState({ latestAssistantMessageId: null });
|
|
274
|
+
|
|
275
|
+
await run();
|
|
276
|
+
|
|
277
|
+
expect(emitCalls).toHaveLength(0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("stays silent when the reply flattens to nothing", async () => {
|
|
281
|
+
// A run that ended on tool calls with no closing prose: whitespace and
|
|
282
|
+
// markdown scaffolding only. Nothing to say, so nothing is said.
|
|
283
|
+
assistantRow = makeAssistantRow([
|
|
284
|
+
{ type: "text", text: " \n\n \n" },
|
|
285
|
+
] as ContentBlock[]);
|
|
286
|
+
|
|
287
|
+
await run();
|
|
288
|
+
|
|
289
|
+
expect(emitCalls).toHaveLength(0);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("notifies for an honest empty-handed result", async () => {
|
|
293
|
+
// "Nothing changed" is the briefing's answer, not an absence of one. The
|
|
294
|
+
// user picked this cadence; a run with a real finding still reports.
|
|
295
|
+
assistantRow = makeAssistantRow([
|
|
296
|
+
{ type: "text", text: "No new mail since yesterday." },
|
|
297
|
+
] as ContentBlock[]);
|
|
298
|
+
|
|
299
|
+
await run();
|
|
300
|
+
|
|
301
|
+
expect(emitCalls).toHaveLength(1);
|
|
302
|
+
expect(emitCalls[0].contextPayload.requestedMessage).toBe(
|
|
303
|
+
"No new mail since yesterday.",
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("keeps markdown in the body so the detail panel renders it", async () => {
|
|
308
|
+
assistantRow = makeAssistantRow([
|
|
309
|
+
{ type: "text", text: "- **PR #12** merged\n- `deploy.sh` failed" },
|
|
310
|
+
] as ContentBlock[]);
|
|
311
|
+
|
|
312
|
+
await run();
|
|
313
|
+
|
|
314
|
+
expect(emitCalls[0].contextPayload.requestedMessage).toBe(
|
|
315
|
+
"- **PR #12** merged\n- `deploy.sh` failed",
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("emits at medium urgency so it neither posts silently nor alarms", async () => {
|
|
320
|
+
await run();
|
|
321
|
+
|
|
322
|
+
expect(emitCalls[0].attentionHints.urgency).toBe("medium");
|
|
323
|
+
expect(emitCalls[0].attentionHints.requiresAction).toBe(false);
|
|
324
|
+
expect(emitCalls[0].attentionHints.isAsyncBackground).toBe(true);
|
|
325
|
+
expect(emitCalls[0].attentionHints.visibleInSourceNow).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("keys dedupe on the run so sub-hourly schedules are not collapsed", async () => {
|
|
329
|
+
// The pipeline's dedupe window is a flat hour. A schedule-scoped key would
|
|
330
|
+
// silently drop every run after the first within that hour.
|
|
331
|
+
await run();
|
|
332
|
+
|
|
333
|
+
expect(emitCalls[0].dedupeKey).toBe(
|
|
334
|
+
`schedule.result:${SCHEDULE_ID}:${RUN_ID}`,
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("clamps an unbounded schedule name into a title", async () => {
|
|
339
|
+
await run({ scheduleName: "x".repeat(200) });
|
|
340
|
+
|
|
341
|
+
expect(
|
|
342
|
+
emitCalls[0].contextPayload.requestedTitle.length,
|
|
343
|
+
).toBeLessThanOrEqual(60);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("truncates a runaway reply rather than carrying it whole", async () => {
|
|
347
|
+
assistantRow = makeAssistantRow([
|
|
348
|
+
{ type: "text", text: "y".repeat(5000) },
|
|
349
|
+
] as ContentBlock[]);
|
|
350
|
+
|
|
351
|
+
await run();
|
|
352
|
+
|
|
353
|
+
expect(
|
|
354
|
+
emitCalls[0].contextPayload.requestedMessage.length,
|
|
355
|
+
).toBeLessThanOrEqual(2000);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("stays silent when the flag is off", async () => {
|
|
359
|
+
setOverridesForTesting({ "schedule-result-notify": false });
|
|
360
|
+
|
|
361
|
+
await run();
|
|
362
|
+
|
|
363
|
+
expect(emitCalls).toHaveLength(0);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("stays silent for a bootstrap-failure sentinel conversation id", async () => {
|
|
367
|
+
await run({ conversationId: `bootstrap-error:${SCHEDULE_ID}` });
|
|
368
|
+
|
|
369
|
+
expect(emitCalls).toHaveLength(0);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("swallows an emit failure so a successful run is not failed by it", async () => {
|
|
373
|
+
mock.module("../emit-signal.js", () => ({
|
|
374
|
+
emitNotificationSignal: async () => {
|
|
375
|
+
throw new Error("simulated pipeline failure");
|
|
376
|
+
},
|
|
377
|
+
}));
|
|
378
|
+
const { emitScheduleResultNotification: emitWithBrokenPipeline } =
|
|
379
|
+
await import("../schedule-result-producer.js");
|
|
380
|
+
|
|
381
|
+
await emitWithBrokenPipeline({
|
|
382
|
+
scheduleId: SCHEDULE_ID,
|
|
383
|
+
scheduleName: "Morning briefing",
|
|
384
|
+
conversationId: CONVERSATION_ID,
|
|
385
|
+
runId: RUN_ID,
|
|
386
|
+
runStartedAt: RUN_STARTED_AT,
|
|
387
|
+
rlog,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Asserting the cause, not just that something warned: a re-import that
|
|
391
|
+
// failed for an unrelated reason would also land in the catch.
|
|
392
|
+
expect(warnCalls).toHaveLength(1);
|
|
393
|
+
expect((warnCalls[0] as any[])[0].err.message).toBe(
|
|
394
|
+
"simulated pipeline failure",
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -172,6 +172,15 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
|
|
|
172
172
|
body: str(payload.message, "A reminder has fired"),
|
|
173
173
|
}),
|
|
174
174
|
|
|
175
|
+
// Unreachable on the happy path: the producer always carries
|
|
176
|
+
// `requestedMessage`, so `composeFallbackCopy`'s verbatim branch answers
|
|
177
|
+
// first. This is the floor for a signal whose body was lost, and it names
|
|
178
|
+
// the schedule so the user can still find the run.
|
|
179
|
+
"schedule.result": (payload) => ({
|
|
180
|
+
title: sanitizedPayloadField(payload.scheduleName, "Schedule"),
|
|
181
|
+
body: "A scheduled run finished with results.",
|
|
182
|
+
}),
|
|
183
|
+
|
|
175
184
|
// The schedule.* fields below (names, cadence, error reason) originate in
|
|
176
185
|
// plugin-authored declaration files, so they are sanitized like any other
|
|
177
186
|
// untrusted actor-controlled string before interpolation.
|
|
@@ -311,7 +320,8 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
|
|
|
311
320
|
);
|
|
312
321
|
const verb = parsed?.decision === "approved" ? "approved" : "denied";
|
|
313
322
|
return {
|
|
314
|
-
|
|
323
|
+
// The outcome is the headline; who decided it is the body's to say.
|
|
324
|
+
title: `Access request ${verb}`,
|
|
315
325
|
body: `${requesterLabel}'s access request has been ${verb} by ${decidedByLabel}.`,
|
|
316
326
|
};
|
|
317
327
|
},
|
|
@@ -326,15 +336,27 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
|
|
|
326
336
|
body: str(payload.body, "A watcher event requires your attention"),
|
|
327
337
|
}),
|
|
328
338
|
|
|
329
|
-
"tool_confirmation.required_action": (payload) =>
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
339
|
+
"tool_confirmation.required_action": (payload) => {
|
|
340
|
+
const toolName = str(payload.toolName, "A tool");
|
|
341
|
+
return {
|
|
342
|
+
title: `${toolName} needs your confirmation`,
|
|
343
|
+
body: `${toolName} requires your confirmation`,
|
|
344
|
+
};
|
|
345
|
+
},
|
|
333
346
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
347
|
+
// Titled by what was done rather than by the kind of event: the summary's
|
|
348
|
+
// first sentence is the outcome ("Finished the fuel-system diagnostic app"),
|
|
349
|
+
// which is what a reader scanning the bell wants to see. A summary without
|
|
350
|
+
// one still gets a title that says something happened.
|
|
351
|
+
"activity.complete": (payload) => {
|
|
352
|
+
const summary = nonEmpty(
|
|
353
|
+
typeof payload.summary === "string" ? payload.summary : undefined,
|
|
354
|
+
);
|
|
355
|
+
return {
|
|
356
|
+
title: summary ? deriveTitle(summary) : "Activity complete",
|
|
357
|
+
body: summary ?? "An activity has completed",
|
|
358
|
+
};
|
|
359
|
+
},
|
|
338
360
|
|
|
339
361
|
"activity.failed": (payload) => {
|
|
340
362
|
const jobName = str(payload.jobName, "background job");
|
|
@@ -357,12 +379,12 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
|
|
|
357
379
|
},
|
|
358
380
|
|
|
359
381
|
"quick_chat.response_ready": (payload) => ({
|
|
360
|
-
title: "
|
|
382
|
+
title: "Quick chat reply ready",
|
|
361
383
|
body: str(payload.preview, "Your quick chat response is ready"),
|
|
362
384
|
}),
|
|
363
385
|
|
|
364
386
|
"voice.response_ready": (payload) => ({
|
|
365
|
-
title: "Voice
|
|
387
|
+
title: "Voice reply ready",
|
|
366
388
|
body: str(payload.preview, "A voice response is ready"),
|
|
367
389
|
}),
|
|
368
390
|
};
|