@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.
- package/openapi.yaml +45 -0
- package/package.json +1 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
- package/src/__tests__/conversation-lifecycle.test.ts +20 -26
- package/src/__tests__/conversation-pairing.test.ts +186 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +21 -2
- package/src/__tests__/notification-vellum-adapter.test.ts +124 -1
- package/src/__tests__/personal-memory-auth-bypass.test.ts +22 -6
- package/src/daemon/conversation.ts +10 -10
- package/src/daemon/trust-context.ts +0 -1
- package/src/messaging/provider-message-metadata.ts +128 -0
- package/src/messaging/providers/slack/message-metadata.ts +45 -0
- package/src/messaging/read-provider-metadata.test.ts +157 -0
- package/src/messaging/read-provider-metadata.ts +51 -0
- package/src/notifications/__tests__/edit-notification.test.ts +174 -2
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +536 -4
- package/src/notifications/adapters/macos.ts +55 -0
- package/src/notifications/conversation-pairing.ts +111 -21
- package/src/notifications/edit-notification.ts +38 -8
- package/src/notifications/emit-signal.ts +12 -14
- package/src/notifications/home-feed-side-effect.ts +218 -15
- package/src/notifications/types.ts +5 -0
- package/src/persistence/conversation-crud.ts +6 -2
- package/src/persistence/delivery-crud.ts +18 -16
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +3 -1
- package/src/runtime/AGENTS.md +35 -0
- package/src/runtime/effective-capabilities.test.ts +8 -32
- package/src/runtime/effective-capabilities.ts +15 -17
- package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +175 -0
- package/src/runtime/routes/approval-routes.ts +37 -4
- package/src/runtime/routes/inbound-message-handler.ts +2 -2
- package/src/subagent/__tests__/consult-context-gating.test.ts +6 -10
- package/src/subagent/consult-context.ts +7 -23
- 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
|
@@ -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
|
-
|
|
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
|
|
656
|
-
// Rehydration keys on
|
|
657
|
-
// `
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
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
|
-
|
|
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
|
|
1051
|
-
//
|
|
1052
|
-
// `loadFromDb
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
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:
|
|
1084
|
-
// exposed via `shouldExposePersonalMemory({sourceChannel: "vellum", ...})`.
|
|
1078
|
+
// First load: guardian actor → personal memory exposed.
|
|
1085
1079
|
conversation.setTrustContext({
|
|
1086
|
-
trustClass: "
|
|
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
|
|
1099
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 /
|
|
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
|
|
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
|
-
|
|
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: "
|
|
72
|
+
trustClass: "unverified_contact",
|
|
57
73
|
}),
|
|
58
|
-
).toBe(
|
|
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
|
|
1123
|
-
//
|
|
1124
|
-
// the
|
|
1125
|
-
//
|
|
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
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
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
|
);
|