@vellumai/assistant 0.11.4-dev.202608191709.8efdf64 → 0.11.4-dev.202608191810.7fe7b7a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/openapi.yaml +45 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  4. package/src/__tests__/conversation-lifecycle.test.ts +20 -26
  5. package/src/__tests__/conversation-pairing.test.ts +186 -0
  6. package/src/__tests__/conversation-runtime-assembly.test.ts +21 -2
  7. package/src/__tests__/notification-vellum-adapter.test.ts +124 -1
  8. package/src/__tests__/personal-memory-auth-bypass.test.ts +22 -6
  9. package/src/daemon/conversation.ts +10 -10
  10. package/src/daemon/trust-context.ts +0 -1
  11. package/src/messaging/provider-message-metadata.ts +128 -0
  12. package/src/messaging/providers/slack/message-metadata.ts +45 -0
  13. package/src/messaging/read-provider-metadata.test.ts +157 -0
  14. package/src/messaging/read-provider-metadata.ts +51 -0
  15. package/src/notifications/__tests__/edit-notification.test.ts +174 -2
  16. package/src/notifications/__tests__/home-feed-side-effect.test.ts +536 -4
  17. package/src/notifications/adapters/macos.ts +55 -0
  18. package/src/notifications/conversation-pairing.ts +111 -21
  19. package/src/notifications/edit-notification.ts +38 -8
  20. package/src/notifications/emit-signal.ts +12 -14
  21. package/src/notifications/home-feed-side-effect.ts +218 -15
  22. package/src/notifications/types.ts +5 -0
  23. package/src/persistence/conversation-crud.ts +6 -2
  24. package/src/persistence/delivery-crud.ts +18 -16
  25. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +3 -1
  26. package/src/runtime/AGENTS.md +35 -0
  27. package/src/runtime/effective-capabilities.test.ts +8 -32
  28. package/src/runtime/effective-capabilities.ts +15 -17
  29. package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +175 -0
  30. package/src/runtime/routes/approval-routes.ts +37 -4
  31. package/src/runtime/routes/inbound-message-handler.ts +2 -2
  32. package/src/subagent/__tests__/consult-context-gating.test.ts +6 -10
  33. package/src/subagent/consult-context.ts +7 -23
  34. package/src/tools/subagent/spawn.ts +0 -1
package/openapi.yaml CHANGED
@@ -23035,6 +23035,51 @@ paths:
23035
23035
  additionalProperties: {}
23036
23036
  - type: "null"
23037
23037
  description: Pending secret request or null
23038
+ pendingQuestion:
23039
+ anyOf:
23040
+ - type: object
23041
+ properties:
23042
+ requestId:
23043
+ type: string
23044
+ entries:
23045
+ type: array
23046
+ items:
23047
+ type: object
23048
+ properties:
23049
+ id:
23050
+ type: string
23051
+ question:
23052
+ type: string
23053
+ description:
23054
+ type: string
23055
+ options:
23056
+ type: array
23057
+ items:
23058
+ type: object
23059
+ properties:
23060
+ id:
23061
+ type: string
23062
+ label:
23063
+ type: string
23064
+ description:
23065
+ type: string
23066
+ required:
23067
+ - id
23068
+ - label
23069
+ additionalProperties: false
23070
+ freeTextPlaceholder:
23071
+ type: string
23072
+ required:
23073
+ - id
23074
+ - question
23075
+ - options
23076
+ additionalProperties: false
23077
+ required:
23078
+ - requestId
23079
+ - entries
23080
+ additionalProperties: false
23081
+ - type: "null"
23082
+ description: Outstanding ask_question prompt or null
23038
23083
  interactions:
23039
23084
  type: array
23040
23085
  items:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.4-dev.202608191709.8efdf64",
3
+ "version": "0.11.4-dev.202608191810.7fe7b7a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -99,7 +99,7 @@ mock.module("../persistence/conversation-crud.js", () => ({
99
99
  parseConversation: (row: unknown) => row,
100
100
  provenanceFromTrustContext: () => ({ source: "user" }),
101
101
  relinkAttachments: mock(() => {}),
102
- selectSlackMetaCandidateMetadata: () => null,
102
+ selectProviderMetaCandidateMetadata: () => null,
103
103
  setConversationOriginChannelIfUnset: mock(() => {}),
104
104
  setConversationOriginInterfaceIfUnset: mock(() => {}),
105
105
  setConversationInferenceProfile: mock(() => {}),
@@ -652,14 +652,12 @@ describe("loadFromDb metadata injection rehydration", () => {
652
652
  expect(messages[2].content).toEqual([{ type: "text", text: "Tail" }]);
653
653
  });
654
654
 
655
- test("internal-channel trusted_contact view still rehydrates memoryV2StaticBlock", async () => {
656
- // Rehydration keys on `sourceChannel`, not `trustClass`: injection uses
657
- // `shouldExposePersonalMemory`, which exposes personal memory whenever
658
- // `sourceChannel === "vellum"` regardless of actor trust class. So a
659
- // trusted_contact view arriving over the internal `"vellum"` channel
660
- // rehydrates `memoryV2StaticBlock`. The rehydrate gate must match
661
- // injection so a daemon-restart reload of the same conversation produces
662
- // an identical prefix.
655
+ test("internal-channel trusted_contact view does not rehydrate memoryV2StaticBlock", async () => {
656
+ // Rehydration keys on the actor, not the surface: a trusted_contact is
657
+ // denied personal memory on the internal `"vellum"` channel exactly as it
658
+ // is on a remote one. Injection and rehydration share
659
+ // `canSeePersonalMemory`, so a daemon-restart reload of the same
660
+ // conversation produces an identical prefix.
663
661
  mockConversation = defaultConv();
664
662
  mockDbMessages = [
665
663
  {
@@ -697,13 +695,8 @@ describe("loadFromDb metadata injection rehydration", () => {
697
695
  const messages = conversation.getMessages();
698
696
 
699
697
  expect(messages).toHaveLength(3);
700
- expect(messages[0].content).toEqual([
701
- {
702
- type: "text",
703
- text: "<info>\n## Essentials\n\nAlice prefers VS Code.\n</info>",
704
- },
705
- { type: "text", text: "First" },
706
- ]);
698
+ // The persisted block stays out: the console is a surface, not an actor.
699
+ expect(messages[0].content).toEqual([{ type: "text", text: "First" }]);
707
700
  });
708
701
 
709
702
  test("rehydration order matches injection-time order for the full personal-memory set", async () => {
@@ -1047,12 +1040,14 @@ describe("loadFromDb metadata injection rehydration", () => {
1047
1040
  expect(messages[2].content).toEqual([{ type: "text", text: "Tail" }]);
1048
1041
  });
1049
1042
 
1050
- test("ensureActorScopedHistory reloads when sourceChannel changes within the same trust class", async () => {
1051
- // Regression: cache invalidation previously keyed only on trust class.
1052
- // `loadFromDb` gates `memoryV2StaticBlock` rehydration on `sourceChannel`
1053
- // via `shouldExposePersonalMemory`, so a same-trust-class reuse from a
1054
- // different channel (e.g. internal `vellum` → remote channel) must
1055
- // re-run `loadFromDb` or stale personal-memory exposure persists.
1043
+ test("ensureActorScopedHistory reloads when the actor's memory visibility changes", async () => {
1044
+ // Reuse of a resident conversation by a different actor must re-run
1045
+ // `loadFromDb`, or the incoming actor inherits the previous actor's
1046
+ // transcript with its personal-memory blocks still spliced in.
1047
+ //
1048
+ // Driven by a trust-class change rather than a channel change: personal
1049
+ // memory is keyed on the actor, so two channels at the same class are the
1050
+ // same view and reloading between them would prove nothing.
1056
1051
  mockConversation = defaultConv();
1057
1052
  mockDbMessages = [
1058
1053
  {
@@ -1080,10 +1075,9 @@ describe("loadFromDb metadata injection rehydration", () => {
1080
1075
  ];
1081
1076
 
1082
1077
  const conversation = makeConversation();
1083
- // First load: internal channel, trusted_contact actor → personal memory
1084
- // exposed via `shouldExposePersonalMemory({sourceChannel: "vellum", ...})`.
1078
+ // First load: guardian actor → personal memory exposed.
1085
1079
  conversation.setTrustContext({
1086
- trustClass: "trusted_contact",
1080
+ trustClass: "guardian",
1087
1081
  sourceChannel: "vellum",
1088
1082
  });
1089
1083
  await conversation.ensureActorScopedHistory();
@@ -1095,8 +1089,8 @@ describe("loadFromDb metadata injection rehydration", () => {
1095
1089
  { type: "text", text: "First" },
1096
1090
  ]);
1097
1091
 
1098
- // Reuse with the same trust class but a remote channel. The cache must
1099
- // invalidate and trigger a reload that strips the personal-memory block.
1092
+ // Reuse by a contact. The cache must invalidate and trigger a reload that
1093
+ // strips the personal-memory block.
1100
1094
  conversation.setTrustContext({
1101
1095
  trustClass: "trusted_contact",
1102
1096
  sourceChannel: "telegram",
@@ -47,6 +47,14 @@ const getConversationMock = mock((id: string) => {
47
47
  return mockExistingConversations[id] ?? null;
48
48
  });
49
49
 
50
+ const messagesInvalidated: string[] = [];
51
+
52
+ mock.module("../runtime/sync/resource-sync-events.js", () => ({
53
+ publishConversationMessagesChanged: (conversationId: string) => {
54
+ messagesInvalidated.push(conversationId);
55
+ },
56
+ }));
57
+
50
58
  mock.module("../persistence/conversation-crud.js", () => ({
51
59
  setConversationProcessingStartedAt: () => {},
52
60
  isConversationProcessing: () => false,
@@ -144,6 +152,7 @@ describe("pairDeliveryWithConversation", () => {
144
152
  beforeEach(() => {
145
153
  createConversationMock.mockClear();
146
154
  addMessageMock.mockClear();
155
+ messagesInvalidated.length = 0;
147
156
  getConversationMock.mockClear();
148
157
  getBindingByChannelChatMock.mockClear();
149
158
  upsertOutboundBindingMock.mockClear();
@@ -870,6 +879,183 @@ describe("pairDeliveryWithConversation", () => {
870
879
  expect(addMessageMock).not.toHaveBeenCalled();
871
880
  });
872
881
 
882
+ // ── Passive default: body appended to the producing conversation ───
883
+ //
884
+ // The home feed points "Go to Conversation" at `sourceContextId`, so the
885
+ // body has to land there for that button to open something that contains
886
+ // the notification the user tapped.
887
+
888
+ test("passive vellum signal appends the body to the producing conversation", async () => {
889
+ mockExistingConversations["conv-producer"] = {
890
+ id: "conv-producer",
891
+ source: "schedule",
892
+ title: "Daily Briefing Delivery",
893
+ };
894
+ const signal = makeSignal({
895
+ requiresConversation: undefined,
896
+ sourceContextId: "conv-producer",
897
+ });
898
+ const copy = makeCopy({ body: "Daily Briefing for Tuesday." });
899
+
900
+ const result = await pairDeliveryWithConversation(
901
+ signal,
902
+ "vellum" as NotificationChannel,
903
+ copy,
904
+ );
905
+
906
+ expect(result.conversationId).toBe("conv-producer");
907
+ expect(result.messageId).toBe("msg-001");
908
+ expect(result.createdNewConversation).toBe(false);
909
+ expect(result.conversationFallbackUsed).toBe(false);
910
+ // Appending must never mint a sidebar entry.
911
+ expect(createConversationMock).not.toHaveBeenCalled();
912
+ expect(addMessageMock).toHaveBeenCalledTimes(1);
913
+ const [conversationId, role] = addMessageMock.mock.calls[0]!;
914
+ expect(conversationId).toBe("conv-producer");
915
+ expect(role).toBe("assistant");
916
+ // `addMessage` publishes the metadata tag alone, so a client holding this
917
+ // conversation open needs the messages tag to refetch the transcript.
918
+ expect(messagesInvalidated).toContain("conv-producer");
919
+ });
920
+
921
+ test("appends for a foreground producer the home feed will not mirror", async () => {
922
+ // `resolveHomeFeedMirror` declines a signal that is neither
923
+ // `assistant_tool`, nor flagged async-background, nor produced by a
924
+ // background-typed conversation, so no card is written for this one. The
925
+ // append is deliberately not gated on that: the vellum banner still
926
+ // deep-links to this conversation, and the client suppresses the banner
927
+ // entirely when the user is already viewing it, leaving the transcript as
928
+ // the only place the notification can land.
929
+ mockExistingConversations["conv-foreground"] = {
930
+ id: "conv-foreground",
931
+ source: "user",
932
+ title: "An ordinary chat",
933
+ };
934
+ const signal = makeSignal({
935
+ requiresConversation: undefined,
936
+ sourceChannel: "plugin",
937
+ sourceContextId: "conv-foreground",
938
+ attentionHints: {
939
+ requiresAction: false,
940
+ urgency: "medium",
941
+ isAsyncBackground: false,
942
+ visibleInSourceNow: false,
943
+ },
944
+ });
945
+ const copy = makeCopy({ body: "Something you asked to watch changed." });
946
+
947
+ const result = await pairDeliveryWithConversation(
948
+ signal,
949
+ "vellum" as NotificationChannel,
950
+ copy,
951
+ );
952
+
953
+ expect(result.conversationId).toBe("conv-foreground");
954
+ expect(createConversationMock).not.toHaveBeenCalled();
955
+ expect(addMessageMock).toHaveBeenCalledTimes(1);
956
+ expect(messagesInvalidated).toContain("conv-foreground");
957
+ });
958
+
959
+ test("appended notification body skips indexing", async () => {
960
+ mockExistingConversations["conv-producer"] = {
961
+ id: "conv-producer",
962
+ source: "schedule",
963
+ title: null,
964
+ };
965
+ const signal = makeSignal({
966
+ requiresConversation: undefined,
967
+ sourceContextId: "conv-producer",
968
+ });
969
+
970
+ await pairDeliveryWithConversation(
971
+ signal,
972
+ "vellum" as NotificationChannel,
973
+ makeCopy(),
974
+ );
975
+
976
+ const options = addMessageMock.mock.calls[0]![3] as {
977
+ skipIndexing?: boolean;
978
+ };
979
+ expect(options.skipIndexing).toBe(true);
980
+ });
981
+
982
+ test("passive vellum signal appends regardless of the producing conversation's source", async () => {
983
+ // The reuse_existing branch requires a source match; appending to the
984
+ // producing conversation does not, since the row already exists and the
985
+ // feed links to it either way.
986
+ mockExistingConversations["conv-producer"] = {
987
+ id: "conv-producer",
988
+ source: "user",
989
+ title: null,
990
+ };
991
+ const signal = makeSignal({
992
+ requiresConversation: undefined,
993
+ sourceContextId: "conv-producer",
994
+ conversationMetadata: { source: "notification" },
995
+ });
996
+
997
+ const result = await pairDeliveryWithConversation(
998
+ signal,
999
+ "vellum" as NotificationChannel,
1000
+ makeCopy(),
1001
+ );
1002
+
1003
+ expect(result.conversationId).toBe("conv-producer");
1004
+ expect(createConversationMock).not.toHaveBeenCalled();
1005
+ });
1006
+
1007
+ test("passive vellum signal with a sentinel sourceContextId appends nothing", async () => {
1008
+ // Producers pass job ids, call session ids and `access-req-*` strings
1009
+ // here. Those resolve to no row, which matches the feed hiding the button.
1010
+ const signal = makeSignal({
1011
+ requiresConversation: undefined,
1012
+ sourceContextId: "job-1234",
1013
+ });
1014
+
1015
+ const result = await pairDeliveryWithConversation(
1016
+ signal,
1017
+ "vellum" as NotificationChannel,
1018
+ makeCopy(),
1019
+ );
1020
+
1021
+ expect(result.conversationId).toBeNull();
1022
+ expect(result.messageId).toBeNull();
1023
+ expect(createConversationMock).not.toHaveBeenCalled();
1024
+ expect(addMessageMock).not.toHaveBeenCalled();
1025
+ });
1026
+
1027
+ test("passive vellum signal appends to the producer, not the reuse_existing target", async () => {
1028
+ mockExistingConversations["conv-producer"] = {
1029
+ id: "conv-producer",
1030
+ source: "schedule",
1031
+ title: null,
1032
+ };
1033
+ mockExistingConversations["conv-hinted"] = {
1034
+ id: "conv-hinted",
1035
+ source: "notification",
1036
+ title: null,
1037
+ };
1038
+ const signal = makeSignal({
1039
+ requiresConversation: undefined,
1040
+ sourceContextId: "conv-producer",
1041
+ });
1042
+ const conversationAction: ConversationAction = {
1043
+ action: "reuse_existing",
1044
+ conversationId: "conv-hinted",
1045
+ };
1046
+
1047
+ const result = await pairDeliveryWithConversation(
1048
+ signal,
1049
+ "vellum" as NotificationChannel,
1050
+ makeCopy(),
1051
+ { conversationAction },
1052
+ );
1053
+
1054
+ expect(result.conversationId).toBe("conv-producer");
1055
+ expect(addMessageMock).toHaveBeenCalledTimes(1);
1056
+ expect(addMessageMock.mock.calls[0]![0]).toBe("conv-producer");
1057
+ });
1058
+
873
1059
  // ── conversationMetadata.conversationType override ─────────────────
874
1060
 
875
1061
  test("uses conversationMetadata.conversationType when set, overriding channel strategy", async () => {
@@ -1496,6 +1496,17 @@ describe("stripInjectionsForCompaction memory/info wrapper matching", () => {
1496
1496
  // applyRuntimeInjections with nowScratchpad
1497
1497
  // ---------------------------------------------------------------------------
1498
1498
 
1499
+ /**
1500
+ * Personal-memory content (NOW.md, PKB, v3 cards) is gated on the actor's
1501
+ * trust class, so suites asserting that content have to name the actor they
1502
+ * are about. Left unset, the injector substitutes the low-trust fallback and
1503
+ * the assertion silently becomes a test of the gate instead of the content.
1504
+ */
1505
+ const GUARDIAN_TRUST_FIXTURE = {
1506
+ trustClass: "guardian",
1507
+ sourceChannel: "vellum",
1508
+ } as const;
1509
+
1499
1510
  describe("applyRuntimeInjections with nowScratchpad", () => {
1500
1511
  const baseMessages: Message[] = [
1501
1512
  {
@@ -1513,6 +1524,7 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1513
1524
  seedNowScratchpad("Current focus: fix the bug");
1514
1525
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1515
1526
  conversationId: FALLBACK_CONVERSATION_ID,
1527
+ trust: GUARDIAN_TRUST_FIXTURE,
1516
1528
  });
1517
1529
 
1518
1530
  expect(result.length).toBe(1);
@@ -1527,6 +1539,7 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1527
1539
  seedNowScratchpad("scratchpad notes");
1528
1540
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1529
1541
  conversationId: FALLBACK_CONVERSATION_ID,
1542
+ trust: GUARDIAN_TRUST_FIXTURE,
1530
1543
  });
1531
1544
 
1532
1545
  // Scratchpad comes first (before user content)
@@ -1542,6 +1555,7 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1542
1555
  test("does not inject when the NOW.md file is absent", async () => {
1543
1556
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1544
1557
  conversationId: FALLBACK_CONVERSATION_ID,
1558
+ trust: GUARDIAN_TRUST_FIXTURE,
1545
1559
  });
1546
1560
 
1547
1561
  expect(result.length).toBe(1);
@@ -1552,6 +1566,7 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1552
1566
  seedNowScratchpad("Current focus: fix the bug");
1553
1567
  const { messages: result } = await applyRuntimeInjections(baseMessages, {
1554
1568
  conversationId: FALLBACK_CONVERSATION_ID,
1569
+ trust: GUARDIAN_TRUST_FIXTURE,
1555
1570
  mode: "minimal",
1556
1571
  });
1557
1572
 
@@ -2873,6 +2888,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
2873
2888
  function makePkbOptions(overrides: Record<string, unknown> = {}) {
2874
2889
  return {
2875
2890
  conversationId: FALLBACK_CONVERSATION_ID,
2891
+ trust: GUARDIAN_TRUST_FIXTURE,
2876
2892
  ...overrides,
2877
2893
  };
2878
2894
  }
@@ -3159,7 +3175,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
3159
3175
  // filtered out.
3160
3176
  const { messages: initialResult } = await applyRuntimeInjections(
3161
3177
  preCompactionMessages,
3162
- { conversationId: FALLBACK_CONVERSATION_ID },
3178
+ makePkbOptions(),
3163
3179
  );
3164
3180
  // Unwrap the injected reminder from the last user message.
3165
3181
  const initialTexts = extractTexts(initialResult);
@@ -3193,7 +3209,7 @@ describe("applyRuntimeInjections — PKB relevance hints", () => {
3193
3209
  // should be filtered, and beta (no longer "in context") should appear.
3194
3210
  const { messages: rebuiltResult } = await applyRuntimeInjections(
3195
3211
  postCompactionMessages,
3196
- { conversationId: FALLBACK_CONVERSATION_ID },
3212
+ makePkbOptions(),
3197
3213
  );
3198
3214
  const rebuiltTexts = extractTexts(rebuiltResult);
3199
3215
  const rebuiltReminder = rebuiltTexts.find(
@@ -6652,6 +6668,7 @@ describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
6652
6668
  pkbSearchThrows = null;
6653
6669
  const { blocks } = await applyRuntimeInjections(baseMessages, {
6654
6670
  conversationId: FALLBACK_CONVERSATION_ID,
6671
+ trust: GUARDIAN_TRUST_FIXTURE,
6655
6672
  mode: "full",
6656
6673
  });
6657
6674
 
@@ -6663,6 +6680,7 @@ describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
6663
6680
  seedPkbContent();
6664
6681
  const { blocks } = await applyRuntimeInjections(baseMessages, {
6665
6682
  conversationId: FALLBACK_CONVERSATION_ID,
6683
+ trust: GUARDIAN_TRUST_FIXTURE,
6666
6684
  mode: "minimal",
6667
6685
  });
6668
6686
 
@@ -6672,6 +6690,7 @@ describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
6672
6690
  test("not captured when PKB inactive", async () => {
6673
6691
  const { blocks } = await applyRuntimeInjections(baseMessages, {
6674
6692
  conversationId: FALLBACK_CONVERSATION_ID,
6693
+ trust: GUARDIAN_TRUST_FIXTURE,
6675
6694
  mode: "full",
6676
6695
  });
6677
6696
 
@@ -1,4 +1,28 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ── Mocks: declared before imports that depend on them ──────────────
4
+
5
+ let updateMessageContentShouldThrow = false;
6
+
7
+ const updateMessageContentMock = mock(
8
+ (_messageId: string, _content: string) => {
9
+ if (updateMessageContentShouldThrow) {
10
+ throw new Error("DB write failed");
11
+ }
12
+ },
13
+ );
14
+
15
+ mock.module("../persistence/conversation-crud.js", () => ({
16
+ updateMessageContent: updateMessageContentMock,
17
+ }));
18
+
19
+ const messagesInvalidated: string[] = [];
20
+
21
+ mock.module("../runtime/sync/resource-sync-events.js", () => ({
22
+ publishConversationMessagesChanged: (conversationId: string) => {
23
+ messagesInvalidated.push(conversationId);
24
+ },
25
+ }));
2
26
 
3
27
  import type { AssistantEvent } from "../api/index.js";
4
28
  import { VellumAdapter } from "../notifications/adapters/macos.js";
@@ -156,3 +180,102 @@ describe("VellumAdapter remotePushDispatched pass-through", () => {
156
180
  expect(intent.remotePushDispatched).toBeUndefined();
157
181
  });
158
182
  });
183
+
184
+ describe("VellumAdapter update", () => {
185
+ beforeEach(() => {
186
+ updateMessageContentMock.mockClear();
187
+ updateMessageContentShouldThrow = false;
188
+ messagesInvalidated.length = 0;
189
+ });
190
+
191
+ test("rewrites the persisted conversation message with the new body", async () => {
192
+ const { adapter } = captureBroadcast();
193
+
194
+ const result = await adapter.update!(
195
+ {
196
+ deliveryId: "delivery-1",
197
+ destination: "vellum",
198
+ messageId: "msg-1",
199
+ conversationId: "conv-1",
200
+ },
201
+ { title: "Daily Briefing", body: "Revised briefing text." },
202
+ );
203
+
204
+ expect(result.success).toBe(true);
205
+ expect(result.messageId).toBe("msg-1");
206
+ expect(updateMessageContentMock).toHaveBeenCalledTimes(1);
207
+ expect(updateMessageContentMock.mock.calls[0]).toEqual([
208
+ "msg-1",
209
+ "Revised briefing text.",
210
+ ]);
211
+ // A client holding the conversation open refetches on this tag alone.
212
+ expect(messagesInvalidated).toEqual(["conv-1"]);
213
+ });
214
+
215
+ test("rewrites without a conversation to invalidate", async () => {
216
+ // Deliveries recorded before the pairing carried a conversation have none
217
+ // to name, and the rewrite still stands on its own.
218
+ const { adapter } = captureBroadcast();
219
+
220
+ const result = await adapter.update!(
221
+ { deliveryId: "delivery-1", destination: "vellum", messageId: "msg-1" },
222
+ { body: "Revised briefing text." },
223
+ );
224
+
225
+ expect(result.success).toBe(true);
226
+ expect(updateMessageContentMock).toHaveBeenCalledTimes(1);
227
+ expect(messagesInvalidated).toHaveLength(0);
228
+ });
229
+
230
+ test("leaves the message untouched on a title-only edit", async () => {
231
+ // The feed rewrites its summary only when the patch carries a body, so
232
+ // writing the title here would put the conversation out of step with the
233
+ // card it is supposed to match.
234
+ const { adapter } = captureBroadcast();
235
+
236
+ const result = await adapter.update!(
237
+ { deliveryId: "delivery-1", destination: "vellum", messageId: "msg-1" },
238
+ { title: "Daily Briefing" },
239
+ );
240
+
241
+ expect(result.success).toBe(true);
242
+ expect(updateMessageContentMock).not.toHaveBeenCalled();
243
+ });
244
+
245
+ test("writes an empty body through, matching the feed summary patch", async () => {
246
+ const { adapter } = captureBroadcast();
247
+
248
+ await adapter.update!(
249
+ { deliveryId: "delivery-1", destination: "vellum", messageId: "msg-1" },
250
+ { title: "Daily Briefing", body: "" },
251
+ );
252
+
253
+ expect(updateMessageContentMock.mock.calls[0]![1]).toBe("");
254
+ });
255
+
256
+ test("skips deliveries that persisted no conversation message", async () => {
257
+ const { adapter } = captureBroadcast();
258
+
259
+ const result = await adapter.update!(
260
+ { deliveryId: "delivery-1", destination: "vellum", messageId: null },
261
+ { body: "Revised briefing text." },
262
+ );
263
+
264
+ expect(result.success).toBe(false);
265
+ expect(result.error).toContain("missing_message_id");
266
+ expect(updateMessageContentMock).not.toHaveBeenCalled();
267
+ });
268
+
269
+ test("reports failure without throwing when the write fails", async () => {
270
+ const { adapter } = captureBroadcast();
271
+ updateMessageContentShouldThrow = true;
272
+
273
+ const result = await adapter.update!(
274
+ { deliveryId: "delivery-1", destination: "vellum", messageId: "msg-1" },
275
+ { body: "Revised briefing text." },
276
+ );
277
+
278
+ expect(result.success).toBe(false);
279
+ expect(result.error).toBe("DB write failed");
280
+ });
281
+ });
@@ -14,7 +14,10 @@
14
14
  */
15
15
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
16
16
 
17
- import { isPersonalMemoryAllowed } from "../daemon/trust-context.js";
17
+ import {
18
+ FALLBACK_TURN_TRUST,
19
+ isPersonalMemoryAllowed,
20
+ } from "../daemon/trust-context.js";
18
21
  import type { TrustContext } from "../daemon/trust-context-types.js";
19
22
  import type { TrustClass } from "../runtime/actor-trust-resolver.js";
20
23
 
@@ -45,16 +48,29 @@ describe("isPersonalMemoryAllowed — no exposure to non-guardian channel actors
45
48
  expect(isPersonalMemoryAllowed(slack("unverified_contact"))).toBe(false);
46
49
  });
47
50
 
48
- test("allows personal memory for the guardian and for local / internal turns", () => {
51
+ test("allows personal memory for the guardian and for local / native turns", () => {
49
52
  expect(isPersonalMemoryAllowed(slack("guardian"))).toBe(true);
50
- // Local/native turn — no resolved actor.
53
+ // Local/native turn: no actor bound at all. `resolveTrustClass` elevates
54
+ // this to guardian under a disabled-auth posture, so identity resolution
55
+ // admits it rather than the memory gate carving out an exception.
51
56
  expect(isPersonalMemoryAllowed(undefined)).toBe(true);
52
- // Internal vellum-channel flow.
57
+ });
58
+
59
+ test("blocks personal memory for the fail-closed fallback trust", () => {
60
+ // `{vellum, unknown}` is FALLBACK_TURN_TRUST, not an internal flow: it is
61
+ // what the daemon substitutes when no per-turn actor was captured. Its own
62
+ // contract is to bias to `unknown` "so a missing snapshot cannot
63
+ // accidentally grant elevated trust", and every other consumer treats it
64
+ // as low-trust and never guardian. Personal memory is elevated trust, so
65
+ // the gate has to agree with them.
66
+ expect(isPersonalMemoryAllowed(FALLBACK_TURN_TRUST)).toBe(false);
67
+ // And a resolved non-guardian is denied on the console for the same
68
+ // reason: a surface is not an actor.
53
69
  expect(
54
70
  isPersonalMemoryAllowed({
55
71
  sourceChannel: "vellum",
56
- trustClass: "unknown",
72
+ trustClass: "unverified_contact",
57
73
  }),
58
- ).toBe(true);
74
+ ).toBe(false);
59
75
  });
60
76
  });
@@ -1119,11 +1119,10 @@ export class Conversation {
1119
1119
  preStrippedCount = boundary === -1 ? slicedDbMessages.length : boundary;
1120
1120
  }
1121
1121
 
1122
- // The injection-time personal-memory gate, so background/local
1123
- // conversations (sourceChannel `undefined` or `"vellum"`) can rehydrate
1124
- // the persisted v2 static memory block. The shared helper folds in the
1125
- // HTTP-auth-disabled dev bypass so rehydration and injection agree on the
1126
- // effective trust class.
1122
+ // The injection-time personal-memory gate, so rehydration of the persisted
1123
+ // blocks admits exactly the actors injection would. The shared helper folds
1124
+ // in the HTTP-auth-disabled dev bypass, so a turn with no bound actor
1125
+ // resolves the same way on both paths.
1127
1126
  const personalMemoryAllowed = isPersonalMemoryAllowed(this.trustContext);
1128
1127
  // Pruned v3 card slugs, read lazily on the first row that carries a v3
1129
1128
  // block (most conversations carry none, so most loads never query). The
@@ -1513,11 +1512,12 @@ export class Conversation {
1513
1512
 
1514
1513
  async ensureActorScopedHistory(): Promise<void> {
1515
1514
  const currentTrustClass = this.trustContext?.trustClass;
1516
- // `loadFromDb` gates personal-memory rehydration on `sourceChannel` too
1517
- // (via `isPersonalMemoryAllowed`), so a same-trust-class reuse from a
1518
- // different channel (e.g. internal `vellum` → remote channel) must also
1519
- // trigger a reload. Otherwise stale personal-memory blocks can leak to
1520
- // an untrusted remote turn, or be hidden when they should be present.
1515
+ // Tracked alongside the trust class because `loadFromDb` gates
1516
+ // personal-memory rehydration on `isPersonalMemoryAllowed`, which folds in
1517
+ // the disabled-auth elevation of an unbound actor: two contexts can share a
1518
+ // trust class and still differ here. A reuse that changes the answer has to
1519
+ // reload, or stale personal-memory blocks persist into a turn that must not
1520
+ // see them, or stay stripped from one that should.
1521
1521
  const currentPersonalMemoryAllowed = isPersonalMemoryAllowed(
1522
1522
  this.trustContext,
1523
1523
  );