@vellumai/assistant 0.11.3-dev.202608111523.8a0fdc8 → 0.11.3-dev.202608111821.01ecb1d
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/package.json +1 -1
- package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
- package/src/__tests__/non-member-access-request.test.ts +0 -20
- package/src/__tests__/run-conversation-turn-persistence.test.ts +247 -140
- package/src/__tests__/secret-ingress-channel.test.ts +0 -1
- package/src/calls/call-setup-flow.ts +0 -1
- package/src/runtime/access-request-helper.ts +9 -12
- package/src/runtime/routes/channel-route-shared.ts +0 -6
- package/src/runtime/routes/inbound-message-handler.ts +5 -24
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -19
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
package/package.json
CHANGED
|
@@ -360,7 +360,6 @@ describe("CallSetupFlow name capture", () => {
|
|
|
360
360
|
).toEqual({ from: PHONE_NUMBER, callerName: CALLER_NAME });
|
|
361
361
|
expect(f.notifyCalls).toEqual([
|
|
362
362
|
{
|
|
363
|
-
canonicalAssistantId: "self",
|
|
364
363
|
sourceChannel: "phone",
|
|
365
364
|
conversationExternalId: PHONE_NUMBER,
|
|
366
365
|
actorExternalId: PHONE_NUMBER,
|
|
@@ -501,7 +501,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
501
501
|
|
|
502
502
|
test("notifyGuardianOfAccessRequest returns no_sender_id when actorExternalId is absent", async () => {
|
|
503
503
|
const result = await notifyGuardianOfAccessRequest({
|
|
504
|
-
canonicalAssistantId: "self",
|
|
505
504
|
sourceChannel: "telegram",
|
|
506
505
|
conversationExternalId: "chat-123",
|
|
507
506
|
actorExternalId: undefined,
|
|
@@ -522,7 +521,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
522
521
|
|
|
523
522
|
test("notifyGuardianOfAccessRequest creates request with self-healed principal when no binding exists", async () => {
|
|
524
523
|
const result = await notifyGuardianOfAccessRequest({
|
|
525
|
-
canonicalAssistantId: "self",
|
|
526
524
|
sourceChannel: "telegram",
|
|
527
525
|
conversationExternalId: "chat-123",
|
|
528
526
|
actorExternalId: "unknown-user",
|
|
@@ -565,7 +563,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
565
563
|
});
|
|
566
564
|
|
|
567
565
|
const result = await notifyGuardianOfAccessRequest({
|
|
568
|
-
canonicalAssistantId: "self",
|
|
569
566
|
sourceChannel: "telegram",
|
|
570
567
|
conversationExternalId: "tg-chat",
|
|
571
568
|
actorExternalId: "unknown-tg-user",
|
|
@@ -619,7 +616,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
619
616
|
});
|
|
620
617
|
|
|
621
618
|
const result = await notifyGuardianOfAccessRequest({
|
|
622
|
-
canonicalAssistantId: "self",
|
|
623
619
|
sourceChannel: "telegram",
|
|
624
620
|
conversationExternalId: "chat-123",
|
|
625
621
|
actorExternalId: "unknown-user",
|
|
@@ -660,7 +656,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
660
656
|
});
|
|
661
657
|
|
|
662
658
|
const result = await notifyGuardianOfAccessRequest({
|
|
663
|
-
canonicalAssistantId: "self",
|
|
664
659
|
sourceChannel: "telegram",
|
|
665
660
|
conversationExternalId: "chat-123",
|
|
666
661
|
actorExternalId: "unknown-user",
|
|
@@ -688,7 +683,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
688
683
|
test("notifyGuardianOfAccessRequest resolves the vellum anchor from the gateway delivery", async () => {
|
|
689
684
|
// Only the vellum anchor (seeded in resetState) is served by the gateway.
|
|
690
685
|
const result = await notifyGuardianOfAccessRequest({
|
|
691
|
-
canonicalAssistantId: "self",
|
|
692
686
|
sourceChannel: "telegram",
|
|
693
687
|
conversationExternalId: "chat-123",
|
|
694
688
|
actorExternalId: "unknown-user",
|
|
@@ -722,7 +716,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
722
716
|
|
|
723
717
|
await expect(
|
|
724
718
|
notifyGuardianOfAccessRequest({
|
|
725
|
-
canonicalAssistantId: "self",
|
|
726
719
|
sourceChannel: "telegram",
|
|
727
720
|
conversationExternalId: "chat-123",
|
|
728
721
|
actorExternalId: "unknown-user",
|
|
@@ -739,7 +732,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
739
732
|
|
|
740
733
|
test("notifyGuardianOfAccessRequest for voice channel includes actorDisplayName in contextPayload", async () => {
|
|
741
734
|
const result = await notifyGuardianOfAccessRequest({
|
|
742
|
-
canonicalAssistantId: "self",
|
|
743
735
|
sourceChannel: "phone",
|
|
744
736
|
conversationExternalId: "+15559998888",
|
|
745
737
|
actorExternalId: "+15559998888",
|
|
@@ -770,7 +762,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
770
762
|
|
|
771
763
|
test("notifyGuardianOfAccessRequest includes requestCode in contextPayload", async () => {
|
|
772
764
|
const result = await notifyGuardianOfAccessRequest({
|
|
773
|
-
canonicalAssistantId: "self",
|
|
774
765
|
sourceChannel: "telegram",
|
|
775
766
|
conversationExternalId: "chat-123",
|
|
776
767
|
actorExternalId: "unknown-user",
|
|
@@ -794,7 +785,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
794
785
|
// is created and the status is forwarded to the card payload. (A `revoked`
|
|
795
786
|
// status would instead suppress — covered by the keep-out tests.)
|
|
796
787
|
const result = await notifyGuardianOfAccessRequest({
|
|
797
|
-
canonicalAssistantId: "self",
|
|
798
788
|
sourceChannel: "telegram",
|
|
799
789
|
conversationExternalId: "chat-123",
|
|
800
790
|
actorExternalId: "pending-user",
|
|
@@ -834,7 +824,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
834
824
|
};
|
|
835
825
|
|
|
836
826
|
const result = await notifyGuardianOfAccessRequest({
|
|
837
|
-
canonicalAssistantId: "self",
|
|
838
827
|
sourceChannel: "phone",
|
|
839
828
|
conversationExternalId: "+15556667777",
|
|
840
829
|
actorExternalId: "+15556667777",
|
|
@@ -897,7 +886,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
897
886
|
};
|
|
898
887
|
|
|
899
888
|
const result = await notifyGuardianOfAccessRequest({
|
|
900
|
-
canonicalAssistantId: "self",
|
|
901
889
|
sourceChannel: "telegram",
|
|
902
890
|
conversationExternalId: "chat-123",
|
|
903
891
|
actorExternalId: "unknown-user",
|
|
@@ -929,7 +917,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
929
917
|
// request row. A revoked/blocked contact is deliberately kept out — no new
|
|
930
918
|
// prompt, signal, or pending request.
|
|
931
919
|
const result = await notifyGuardianOfAccessRequest({
|
|
932
|
-
canonicalAssistantId: "self",
|
|
933
920
|
sourceChannel: "telegram",
|
|
934
921
|
conversationExternalId: "chat-kept-out",
|
|
935
922
|
actorExternalId: "revoked-user",
|
|
@@ -969,7 +956,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
969
956
|
});
|
|
970
957
|
|
|
971
958
|
const result = await notifyGuardianOfAccessRequest({
|
|
972
|
-
canonicalAssistantId: "self",
|
|
973
959
|
sourceChannel: "telegram",
|
|
974
960
|
conversationExternalId: "chat-parked",
|
|
975
961
|
actorExternalId: "parked-user",
|
|
@@ -1006,7 +992,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
1006
992
|
});
|
|
1007
993
|
|
|
1008
994
|
const result = await notifyGuardianOfAccessRequest({
|
|
1009
|
-
canonicalAssistantId: "self",
|
|
1010
995
|
sourceChannel: "telegram",
|
|
1011
996
|
conversationExternalId: "chat-approved",
|
|
1012
997
|
actorExternalId: "approved-user",
|
|
@@ -1047,7 +1032,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
1047
1032
|
bridgeState.requests.get(requestId)!.updatedAt = staleUpdatedAt;
|
|
1048
1033
|
|
|
1049
1034
|
const result = await notifyGuardianOfAccessRequest({
|
|
1050
|
-
canonicalAssistantId: "self",
|
|
1051
1035
|
sourceChannel: "telegram",
|
|
1052
1036
|
conversationExternalId: "chat-stale",
|
|
1053
1037
|
actorExternalId: "stale-user",
|
|
@@ -1082,7 +1066,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
1082
1066
|
});
|
|
1083
1067
|
|
|
1084
1068
|
const result = await notifyGuardianOfAccessRequest({
|
|
1085
|
-
canonicalAssistantId: "self",
|
|
1086
1069
|
sourceChannel: "telegram",
|
|
1087
1070
|
conversationExternalId: "chat-flip",
|
|
1088
1071
|
actorExternalId: "flip-user",
|
|
@@ -1101,7 +1084,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
1101
1084
|
// Suppression is scoped to the sender via their own contact status, so a
|
|
1102
1085
|
// kept-out contact never bleeds into a different sender's prompt.
|
|
1103
1086
|
const keptOut = await notifyGuardianOfAccessRequest({
|
|
1104
|
-
canonicalAssistantId: "self",
|
|
1105
1087
|
sourceChannel: "telegram",
|
|
1106
1088
|
conversationExternalId: "chat-kept",
|
|
1107
1089
|
actorExternalId: "revoked-user",
|
|
@@ -1112,7 +1094,6 @@ describe("access-request-helper unit tests", () => {
|
|
|
1112
1094
|
|
|
1113
1095
|
// A different sender with no keep-out still gets a fresh prompt.
|
|
1114
1096
|
const result = await notifyGuardianOfAccessRequest({
|
|
1115
|
-
canonicalAssistantId: "self",
|
|
1116
1097
|
sourceChannel: "telegram",
|
|
1117
1098
|
conversationExternalId: "chat-fresh",
|
|
1118
1099
|
actorExternalId: "fresh-user",
|
|
@@ -1141,7 +1122,6 @@ describe("maybeNotifyGuardianOfAdmittedContact", () => {
|
|
|
1141
1122
|
});
|
|
1142
1123
|
|
|
1143
1124
|
const baseParams = {
|
|
1144
|
-
canonicalAssistantId: "self",
|
|
1145
1125
|
sourceChannel: "telegram" as const,
|
|
1146
1126
|
conversationExternalId: "chat-123",
|
|
1147
1127
|
actorExternalId: "user-unknown-456",
|
|
@@ -1,9 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `runConversationTurn` against the real conversation store, the real agent
|
|
3
|
+
* loop and the real database. The only thing stood in for is the provider's
|
|
4
|
+
* HTTP boundary — `resolveProviderFromConnection` hands back a scripted mock
|
|
5
|
+
* — so everything these tests read back (the `conversations` row, the user
|
|
6
|
+
* message and the metadata stamped on it, the channel binding) is what
|
|
7
|
+
* production wrote, not a stub mirroring what production is believed to do.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
afterEach,
|
|
11
|
+
beforeEach,
|
|
12
|
+
describe,
|
|
13
|
+
expect,
|
|
14
|
+
mock,
|
|
15
|
+
spyOn,
|
|
16
|
+
test,
|
|
17
|
+
} from "bun:test";
|
|
18
|
+
|
|
19
|
+
import type { AssistantEvent } from "../api/index.js";
|
|
20
|
+
import { clearAllActiveConversations } from "../daemon/conversation-store.js";
|
|
21
|
+
import {
|
|
22
|
+
conversationMetadataSyncTag,
|
|
23
|
+
SYNC_TAGS,
|
|
24
|
+
} from "../daemon/message-types/sync.js";
|
|
3
25
|
import {
|
|
4
26
|
createConversation,
|
|
5
|
-
ensureConversationExists,
|
|
6
27
|
getConversation,
|
|
28
|
+
getMessages,
|
|
29
|
+
parseMessageMetadata,
|
|
7
30
|
} from "../persistence/conversation-crud.js";
|
|
8
31
|
import { getConversationByKey } from "../persistence/conversation-key-store.js";
|
|
9
32
|
import {
|
|
@@ -14,97 +37,142 @@ import { getDb } from "../persistence/db-connection.js";
|
|
|
14
37
|
import { initializeDb } from "../persistence/db-init.js";
|
|
15
38
|
import { buildScopedConversationKey } from "../persistence/delivery-crud.js";
|
|
16
39
|
import { getBindingByConversation } from "../persistence/external-conversation-store.js";
|
|
40
|
+
import { createConnection } from "../providers/inference/connections.js";
|
|
41
|
+
import * as providerRegistry from "../providers/registry.js";
|
|
42
|
+
import type { Provider, ProviderResponse } from "../providers/types.js";
|
|
43
|
+
import { createMockProvider, textResponse } from "./helpers/mock-provider.js";
|
|
44
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
45
|
+
import { waitFor } from "./helpers/wait-for.js";
|
|
17
46
|
|
|
18
47
|
await initializeDb();
|
|
19
48
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}));
|
|
32
|
-
|
|
33
|
-
// Stub the heavy machinery: the in-memory conversation build (provider wiring,
|
|
34
|
-
// system prompt, history hydration) and the SSE event fan-out. The agent turn
|
|
35
|
-
// itself is a no-op — this test only asserts that the `conversations` row is
|
|
36
|
-
// persisted before the turn runs. The real `getOrCreateConversation` now
|
|
37
|
-
// creates the DB row before hydrating, so the mock mirrors that by calling
|
|
38
|
-
// `ensureConversationExists`. The persistence module is intentionally NOT
|
|
39
|
-
// mocked so the real `ensureConversationExists` runs against the real DB.
|
|
40
|
-
let lastProcessMessageConversationId: string | undefined;
|
|
41
|
-
let lastProcessMessageOptions: Record<string, unknown> | undefined;
|
|
42
|
-
let lastEnqueueOptions: Record<string, unknown> | undefined;
|
|
43
|
-
let lastTurnChannelContext: unknown;
|
|
44
|
-
let conversationIsProcessing = false;
|
|
45
|
-
mock.module("../daemon/conversation-store.js", () => ({
|
|
46
|
-
getOrCreateConversation: async (
|
|
47
|
-
conversationId: string,
|
|
48
|
-
options?: { conversationType?: string },
|
|
49
|
-
) => {
|
|
50
|
-
if (!getConversation(conversationId)) {
|
|
51
|
-
if (options?.conversationType) {
|
|
52
|
-
createConversation({
|
|
53
|
-
id: conversationId,
|
|
54
|
-
conversationType: options.conversationType as
|
|
55
|
-
| "standard"
|
|
56
|
-
| "background",
|
|
57
|
-
});
|
|
58
|
-
} else {
|
|
59
|
-
ensureConversationExists(conversationId, "vellum");
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return {
|
|
63
|
-
abortController: undefined,
|
|
64
|
-
setTurnChannelContext: (ctx: unknown) => {
|
|
65
|
-
lastTurnChannelContext = ctx;
|
|
66
|
-
},
|
|
67
|
-
isProcessing: () => conversationIsProcessing,
|
|
68
|
-
async processMessage(processOptions: Record<string, unknown>) {
|
|
69
|
-
// The row must already exist by the time the turn persists its user
|
|
70
|
-
// message — record the id so the FK precondition can be asserted.
|
|
71
|
-
lastProcessMessageConversationId = conversationId;
|
|
72
|
-
lastProcessMessageOptions = processOptions;
|
|
73
|
-
return "user-message-id";
|
|
74
|
-
},
|
|
75
|
-
enqueueMessage: (enqueueOptions: Record<string, unknown>) => {
|
|
76
|
-
lastEnqueueOptions = enqueueOptions;
|
|
77
|
-
return { rejected: false };
|
|
78
|
-
},
|
|
79
|
-
};
|
|
49
|
+
// A conversation is built against the default provider, which resolves from
|
|
50
|
+
// config through a connection row. Both are seeded for real so the store takes
|
|
51
|
+
// the same path it takes in the daemon; only the provider the connection
|
|
52
|
+
// resolves to is scripted.
|
|
53
|
+
setConfig("llm", {
|
|
54
|
+
profiles: {
|
|
55
|
+
test: {
|
|
56
|
+
provider: "anthropic",
|
|
57
|
+
provider_connection: "test-conn",
|
|
58
|
+
model: "claude-opus-4-6",
|
|
59
|
+
},
|
|
80
60
|
},
|
|
81
|
-
|
|
61
|
+
activeProfile: "test",
|
|
62
|
+
});
|
|
63
|
+
setConfig("memory", { enabled: false, v2: { enabled: false } });
|
|
64
|
+
|
|
65
|
+
createConnection(getDb(), {
|
|
66
|
+
name: "test-conn",
|
|
67
|
+
provider: "anthropic",
|
|
68
|
+
auth: { type: "api_key", credential: "credential/test/api_key" },
|
|
69
|
+
});
|
|
82
70
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
71
|
+
// The SSE fan-out is the one collaborator with nowhere to write in a test —
|
|
72
|
+
// every publish the turn makes (including the list invalidations asserted
|
|
73
|
+
// below, which reach clients through the real `resource-sync-events`) funnels
|
|
74
|
+
// through here.
|
|
75
|
+
const broadcasts: AssistantEvent[] = [];
|
|
76
|
+
mock.module(
|
|
77
|
+
"../runtime/assistant-event-hub.js",
|
|
78
|
+
(): Partial<typeof import("../runtime/assistant-event-hub.js")> => ({
|
|
79
|
+
broadcastMessage: (msg: AssistantEvent) => {
|
|
80
|
+
broadcasts.push(msg);
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const { provider: scriptedProvider } = createMockProvider([
|
|
86
|
+
textResponse("scripted reply"),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
let gate: Promise<void> | undefined;
|
|
90
|
+
let releaseGate: (() => void) | undefined;
|
|
86
91
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Park the next turn inside the provider call until released, so a second turn
|
|
94
|
+
* arrives while the conversation is genuinely processing rather than while a
|
|
95
|
+
* stub reports that it is.
|
|
96
|
+
*/
|
|
97
|
+
function holdTheTurnOpen(): void {
|
|
98
|
+
let resolveGate!: () => void;
|
|
99
|
+
gate = new Promise<void>((resolve) => {
|
|
100
|
+
resolveGate = resolve;
|
|
101
|
+
});
|
|
102
|
+
releaseGate = () => {
|
|
103
|
+
gate = undefined;
|
|
104
|
+
releaseGate = undefined;
|
|
105
|
+
resolveGate();
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A test that fails before releasing must not leave the next one parked on a
|
|
110
|
+
// gate it never opened.
|
|
111
|
+
afterEach(() => releaseGate?.());
|
|
112
|
+
|
|
113
|
+
const provider: Provider = {
|
|
114
|
+
name: "scripted",
|
|
115
|
+
async sendMessage(messages, options): Promise<ProviderResponse> {
|
|
116
|
+
await gate;
|
|
117
|
+
return scriptedProvider.sendMessage(messages, options);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
spyOn(providerRegistry, "resolveProviderFromConnection").mockResolvedValue(
|
|
122
|
+
provider,
|
|
123
|
+
);
|
|
90
124
|
|
|
91
|
-
// Import under test AFTER the mocks are registered so its dynamic imports
|
|
92
|
-
// resolve to the stubs above.
|
|
93
125
|
const { runConversationTurn } =
|
|
94
126
|
await import("../plugin-api/conversation-turn.js");
|
|
95
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The list-level invalidations a turn published, as the tag sets clients
|
|
130
|
+
* consume. Only a shape-changing reason carries the list umbrella tag, so a
|
|
131
|
+
* reason that merely edits an existing row (a generated title) is excluded —
|
|
132
|
+
* these are the announcements that a conversation appeared or vanished.
|
|
133
|
+
*/
|
|
134
|
+
function listInvalidations(): string[][] {
|
|
135
|
+
return broadcasts
|
|
136
|
+
.filter((msg) => msg.type === "sync_changed")
|
|
137
|
+
.map((msg) => msg.tags)
|
|
138
|
+
.filter((tags) => tags.includes(SYNC_TAGS.conversationsList));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The tag set `publishConversationListAndMetadataChanged` emits for a new row. */
|
|
142
|
+
function creationTags(conversationId: string): string[] {
|
|
143
|
+
return [
|
|
144
|
+
SYNC_TAGS.conversationsList,
|
|
145
|
+
conversationMetadataSyncTag(conversationId),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** User message rows of a conversation, oldest first. */
|
|
150
|
+
function userRows(conversationId: string) {
|
|
151
|
+
return getMessages(conversationId).filter((row) => row.role === "user");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The metadata a turn persisted on a message row, parsed as consumers read it. */
|
|
155
|
+
function metadataOf(
|
|
156
|
+
row: { metadata: string | null } | undefined,
|
|
157
|
+
): Record<string, unknown> | undefined {
|
|
158
|
+
return parseMessageMetadata(row?.metadata ?? null);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resetDb(): void {
|
|
162
|
+
// In-memory conversations outlive the rows they were hydrated from, and a
|
|
163
|
+
// reused id would otherwise hand the next test a Conversation whose
|
|
164
|
+
// `conversations` row this wipe just deleted.
|
|
165
|
+
clearAllActiveConversations();
|
|
166
|
+
const db = getDb();
|
|
167
|
+
db.run("DELETE FROM messages");
|
|
168
|
+
db.run("DELETE FROM external_conversation_bindings");
|
|
169
|
+
db.run("DELETE FROM conversation_keys");
|
|
170
|
+
db.run("DELETE FROM conversations");
|
|
171
|
+
broadcasts.length = 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
96
174
|
describe("runConversationTurn persistence", () => {
|
|
97
|
-
beforeEach(
|
|
98
|
-
const db = getDb();
|
|
99
|
-
db.run("DELETE FROM messages");
|
|
100
|
-
db.run("DELETE FROM conversations");
|
|
101
|
-
listChangedCalls.length = 0;
|
|
102
|
-
lastProcessMessageConversationId = undefined;
|
|
103
|
-
lastProcessMessageOptions = undefined;
|
|
104
|
-
lastEnqueueOptions = undefined;
|
|
105
|
-
lastTurnChannelContext = undefined;
|
|
106
|
-
conversationIsProcessing = false;
|
|
107
|
-
});
|
|
175
|
+
beforeEach(resetDb);
|
|
108
176
|
|
|
109
177
|
test("persists a conversations row for a freshly-minted conversation", async () => {
|
|
110
178
|
const result = await runConversationTurn({
|
|
@@ -112,16 +180,20 @@ describe("runConversationTurn persistence", () => {
|
|
|
112
180
|
});
|
|
113
181
|
|
|
114
182
|
// The row exists on disk — not just as an in-memory Conversation object —
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
183
|
+
// and the user message the turn persisted found its FK target there.
|
|
184
|
+
expect(getConversation(result.conversationId)?.id).toBe(
|
|
185
|
+
result.conversationId,
|
|
186
|
+
);
|
|
187
|
+
expect(userRows(result.conversationId).map((row) => row.id)).toEqual([
|
|
188
|
+
result.userMessageId,
|
|
189
|
+
]);
|
|
119
190
|
|
|
120
191
|
// Siblings/sidebars are told about the new conversation, mirroring the
|
|
121
192
|
// send-message route.
|
|
122
|
-
expect(
|
|
123
|
-
|
|
124
|
-
|
|
193
|
+
expect(listInvalidations()).toEqual([creationTags(result.conversationId)]);
|
|
194
|
+
expect(
|
|
195
|
+
broadcasts.filter((msg) => msg.type === "conversation_list_invalidated"),
|
|
196
|
+
).toEqual([{ type: "conversation_list_invalidated", reason: "created" }]);
|
|
125
197
|
});
|
|
126
198
|
|
|
127
199
|
test("adopts a caller-supplied conversation id verbatim when no row exists", async () => {
|
|
@@ -134,12 +206,12 @@ describe("runConversationTurn persistence", () => {
|
|
|
134
206
|
|
|
135
207
|
expect(result.conversationId).toBe(conversationId);
|
|
136
208
|
expect(getConversation(conversationId)?.id).toBe(conversationId);
|
|
137
|
-
expect(
|
|
209
|
+
expect(listInvalidations()).toEqual([creationTags(conversationId)]);
|
|
138
210
|
});
|
|
139
211
|
|
|
140
212
|
test("is a no-op for an already-persisted conversation row", async () => {
|
|
141
213
|
const existing = createConversation({ title: "already here" });
|
|
142
|
-
|
|
214
|
+
broadcasts.length = 0;
|
|
143
215
|
|
|
144
216
|
const result = await runConversationTurn({
|
|
145
217
|
conversationId: existing.id,
|
|
@@ -149,7 +221,7 @@ describe("runConversationTurn persistence", () => {
|
|
|
149
221
|
expect(result.conversationId).toBe(existing.id);
|
|
150
222
|
// Row is untouched and no duplicate "created" invalidation fires.
|
|
151
223
|
expect(getConversation(existing.id)?.title).toBe("already here");
|
|
152
|
-
expect(
|
|
224
|
+
expect(listInvalidations()).toEqual([]);
|
|
153
225
|
});
|
|
154
226
|
});
|
|
155
227
|
|
|
@@ -159,28 +231,24 @@ describe("runConversationTurn persistence", () => {
|
|
|
159
231
|
// eligibility predicate's verdict on the stamped metadata, so the marker and
|
|
160
232
|
// the gate that reads it cannot drift apart.
|
|
161
233
|
describe("runConversationTurn provenance", () => {
|
|
162
|
-
beforeEach(
|
|
163
|
-
const db = getDb();
|
|
164
|
-
db.run("DELETE FROM messages");
|
|
165
|
-
db.run("DELETE FROM conversations");
|
|
166
|
-
lastProcessMessageOptions = undefined;
|
|
167
|
-
lastEnqueueOptions = undefined;
|
|
168
|
-
conversationIsProcessing = false;
|
|
169
|
-
});
|
|
234
|
+
beforeEach(resetDb);
|
|
170
235
|
|
|
171
236
|
test("stamps the initiating row automated so its reply raises no push", async () => {
|
|
172
237
|
const existing = createConversation({ title: "standard conversation" });
|
|
173
238
|
|
|
174
|
-
await runConversationTurn({
|
|
239
|
+
const result = await runConversationTurn({
|
|
175
240
|
conversationId: existing.id,
|
|
176
241
|
content: [{ type: "text", text: "transcript excerpt" }],
|
|
177
242
|
});
|
|
178
243
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
244
|
+
// The scripted reply came back, so the marker below was stamped by a turn
|
|
245
|
+
// that ran rather than by one that fell over into an error row.
|
|
246
|
+
expect(result.content).toMatchObject([
|
|
247
|
+
{ type: "text", text: "scripted reply" },
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const metadata = metadataOf(userRows(existing.id)[0]);
|
|
251
|
+
expect(metadata).toMatchObject({ automated: true });
|
|
184
252
|
expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
|
|
185
253
|
// Not an echo-suppression marker: the row still renders in the transcript.
|
|
186
254
|
expect(isEchoSuppressedUserMessage(metadata)).toBe(false);
|
|
@@ -188,18 +256,37 @@ describe("runConversationTurn provenance", () => {
|
|
|
188
256
|
|
|
189
257
|
test("stamps the same marker on a turn queued behind a busy conversation", async () => {
|
|
190
258
|
const existing = createConversation({ title: "busy conversation" });
|
|
191
|
-
|
|
259
|
+
holdTheTurnOpen();
|
|
260
|
+
|
|
261
|
+
const inFlight = runConversationTurn({
|
|
262
|
+
conversationId: existing.id,
|
|
263
|
+
content: [{ type: "text", text: "first excerpt" }],
|
|
264
|
+
});
|
|
265
|
+
await waitFor(() => userRows(existing.id).length === 1, {
|
|
266
|
+
timeoutMs: 5_000,
|
|
267
|
+
message: "the first turn never reached the provider",
|
|
268
|
+
});
|
|
192
269
|
|
|
193
270
|
const result = await runConversationTurn({
|
|
194
271
|
conversationId: existing.id,
|
|
195
272
|
content: [{ type: "text", text: "transcript excerpt" }],
|
|
196
273
|
});
|
|
197
|
-
|
|
198
274
|
expect(result.queued).toBe(true);
|
|
199
|
-
|
|
200
|
-
|
|
275
|
+
|
|
276
|
+
// The queue drains after the in-flight turn releases, and the marker has
|
|
277
|
+
// to survive that trip: it is stamped now but read off the persisted row
|
|
278
|
+
// much later.
|
|
279
|
+
releaseGate?.();
|
|
280
|
+
await inFlight;
|
|
281
|
+
await waitFor(() => userRows(existing.id).length === 2, {
|
|
282
|
+
timeoutMs: 5_000,
|
|
283
|
+
message: "the queued turn never drained",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const metadata = metadataOf(userRows(existing.id)[1]);
|
|
287
|
+
expect(metadata).toMatchObject({ automated: true });
|
|
201
288
|
expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
|
|
202
|
-
});
|
|
289
|
+
}, 20_000);
|
|
203
290
|
});
|
|
204
291
|
|
|
205
292
|
/**
|
|
@@ -219,18 +306,7 @@ describe("runConversationTurn channel binding", () => {
|
|
|
219
306
|
displayName: "Ada",
|
|
220
307
|
};
|
|
221
308
|
|
|
222
|
-
beforeEach(
|
|
223
|
-
const db = getDb();
|
|
224
|
-
db.run("DELETE FROM messages");
|
|
225
|
-
db.run("DELETE FROM external_conversation_bindings");
|
|
226
|
-
db.run("DELETE FROM conversation_keys");
|
|
227
|
-
db.run("DELETE FROM conversations");
|
|
228
|
-
listChangedCalls.length = 0;
|
|
229
|
-
lastProcessMessageOptions = undefined;
|
|
230
|
-
lastEnqueueOptions = undefined;
|
|
231
|
-
lastTurnChannelContext = undefined;
|
|
232
|
-
conversationIsProcessing = false;
|
|
233
|
-
});
|
|
309
|
+
beforeEach(resetDb);
|
|
234
310
|
|
|
235
311
|
test("resolves the chat to the conversation inbound would have used", async () => {
|
|
236
312
|
const result = await runConversationTurn({
|
|
@@ -262,9 +338,7 @@ describe("runConversationTurn channel binding", () => {
|
|
|
262
338
|
|
|
263
339
|
expect(second.conversationId).toBe(first.conversationId);
|
|
264
340
|
// Only the first turn minted a conversation.
|
|
265
|
-
expect(
|
|
266
|
-
{ kind: "created", conversationId: first.conversationId },
|
|
267
|
-
]);
|
|
341
|
+
expect(listInvalidations()).toEqual([creationTags(first.conversationId)]);
|
|
268
342
|
});
|
|
269
343
|
|
|
270
344
|
test("gives a different chat its own conversation", async () => {
|
|
@@ -314,18 +388,20 @@ describe("runConversationTurn channel binding", () => {
|
|
|
314
388
|
// Runtime assembly reads the per-turn channel first and only then the
|
|
315
389
|
// conversation's origin, so a turn into a conversation whose origin was
|
|
316
390
|
// never recorded would otherwise run as `vellum`. That is the wrong
|
|
317
|
-
// channel for the message
|
|
391
|
+
// channel for the message rows and for the permission cascade the tools
|
|
318
392
|
// are approved against.
|
|
319
|
-
await runConversationTurn({
|
|
393
|
+
const result = await runConversationTurn({
|
|
320
394
|
channel: CHANNEL,
|
|
321
395
|
content: [{ type: "text", text: "hello" }],
|
|
322
396
|
});
|
|
323
397
|
|
|
324
|
-
|
|
398
|
+
const rows = getMessages(result.conversationId);
|
|
399
|
+
expect(metadataOf(rows[0])).toMatchObject({
|
|
325
400
|
userMessageChannel: "plugin",
|
|
326
401
|
assistantMessageChannel: "plugin",
|
|
327
402
|
});
|
|
328
|
-
expect(
|
|
403
|
+
expect(rows[1].role).toBe("assistant");
|
|
404
|
+
expect(metadataOf(rows[1])).toMatchObject({
|
|
329
405
|
userMessageChannel: "plugin",
|
|
330
406
|
assistantMessageChannel: "plugin",
|
|
331
407
|
});
|
|
@@ -333,32 +409,63 @@ describe("runConversationTurn channel binding", () => {
|
|
|
333
409
|
|
|
334
410
|
test("a queued turn carries its own channel rather than inheriting one", async () => {
|
|
335
411
|
// The drain happens after this call returns and reads the channel off the
|
|
336
|
-
// queued message, falling back to whichever turn was in flight.
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
412
|
+
// queued message, falling back to whichever turn was in flight. Here that
|
|
413
|
+
// fallback would be `vellum` — someone else's channel on a conversation
|
|
414
|
+
// shared with another channel.
|
|
415
|
+
const conversationId = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d";
|
|
416
|
+
holdTheTurnOpen();
|
|
417
|
+
|
|
418
|
+
const inFlight = runConversationTurn({
|
|
419
|
+
conversationId,
|
|
420
|
+
content: [{ type: "text", text: "native turn" }],
|
|
421
|
+
});
|
|
422
|
+
await waitFor(() => userRows(conversationId).length === 1, {
|
|
423
|
+
timeoutMs: 5_000,
|
|
424
|
+
message: "the first turn never reached the provider",
|
|
425
|
+
});
|
|
340
426
|
|
|
341
427
|
const result = await runConversationTurn({
|
|
428
|
+
conversationId,
|
|
342
429
|
channel: CHANNEL,
|
|
343
430
|
content: [{ type: "text", text: "hello" }],
|
|
344
431
|
});
|
|
345
|
-
|
|
346
432
|
expect(result.queued).toBe(true);
|
|
347
|
-
|
|
433
|
+
|
|
434
|
+
releaseGate?.();
|
|
435
|
+
await inFlight;
|
|
436
|
+
await waitFor(() => userRows(conversationId).length === 2, {
|
|
437
|
+
timeoutMs: 5_000,
|
|
438
|
+
message: "the queued turn never drained",
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
expect(metadataOf(userRows(conversationId)[1])).toMatchObject({
|
|
348
442
|
userMessageChannel: "plugin",
|
|
349
443
|
assistantMessageChannel: "plugin",
|
|
350
444
|
});
|
|
351
|
-
});
|
|
445
|
+
}, 20_000);
|
|
352
446
|
|
|
353
447
|
test("leaves the channel unset when the turn names none", async () => {
|
|
354
448
|
// Clearing rather than leaving whatever the last turn set: a stale
|
|
355
449
|
// context would report this turn on a channel it has nothing to do with.
|
|
356
450
|
await runConversationTurn({
|
|
451
|
+
channel: CHANNEL,
|
|
357
452
|
content: [{ type: "text", text: "hello" }],
|
|
358
453
|
});
|
|
359
454
|
|
|
360
|
-
|
|
361
|
-
|
|
455
|
+
const result = await runConversationTurn({
|
|
456
|
+
content: [{ type: "text", text: "hello" }],
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const rows = getMessages(result.conversationId);
|
|
460
|
+
const userMetadata = metadataOf(rows[0]);
|
|
461
|
+
expect(userMetadata).toMatchObject({ automated: true });
|
|
462
|
+
expect(userMetadata?.userMessageChannel).toBeUndefined();
|
|
463
|
+
// The conversation carries no origin either, so the turn falls back to the
|
|
464
|
+
// native channel rather than to the previous turn's.
|
|
465
|
+
expect(metadataOf(rows[1])).toMatchObject({
|
|
466
|
+
userMessageChannel: "vellum",
|
|
467
|
+
assistantMessageChannel: "vellum",
|
|
468
|
+
});
|
|
362
469
|
});
|
|
363
470
|
|
|
364
471
|
test("keeps a known sender when a later turn omits it", async () => {
|
|
@@ -1393,7 +1393,6 @@ export class CallSetupFlow {
|
|
|
1393
1393
|
|
|
1394
1394
|
try {
|
|
1395
1395
|
const accessResult = await ndeps.notifyGuardianOfAccessRequest({
|
|
1396
|
-
canonicalAssistantId: accessRequest.assistantId,
|
|
1397
1396
|
sourceChannel: "phone",
|
|
1398
1397
|
conversationExternalId: accessRequest.fromNumber,
|
|
1399
1398
|
actorExternalId: accessRequest.fromNumber,
|
|
@@ -29,6 +29,7 @@ import { buildVellumCardAffinity } from "../notifications/vellum-card-affinity.j
|
|
|
29
29
|
import { IntegrityError } from "../util/errors.js";
|
|
30
30
|
import { getLogger } from "../util/logger.js";
|
|
31
31
|
import { resolveAnchoredGuardian } from "./anchored-guardian.js";
|
|
32
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from "./assistant-scope.js";
|
|
32
33
|
import { CHALLENGE_TTL_MS } from "./channel-verification-service.js";
|
|
33
34
|
import {
|
|
34
35
|
type AccessRequestTrigger,
|
|
@@ -46,7 +47,6 @@ const log = getLogger("access-request-helper");
|
|
|
46
47
|
export type { AccessRequestTrigger } from "./introduction-policy.js";
|
|
47
48
|
|
|
48
49
|
export interface AccessRequestParams {
|
|
49
|
-
canonicalAssistantId: string;
|
|
50
50
|
sourceChannel: ChannelId;
|
|
51
51
|
conversationExternalId: string;
|
|
52
52
|
actorExternalId?: string;
|
|
@@ -93,15 +93,18 @@ export type AccessRequestResult =
|
|
|
93
93
|
// ---------------------------------------------------------------------------
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
96
|
+
* Conversation id for an actor's access requests. Stable key that dedupes
|
|
97
|
+
* pending prompts for the same (channel, actor).
|
|
98
|
+
*
|
|
99
|
+
* The `self` segment is part of a persisted id format: guardian request rows
|
|
100
|
+
* carry these ids, so the shape is fixed even though the daemon scopes all its
|
|
101
|
+
* storage to the one assistant (`DAEMON_INTERNAL_ASSISTANT_ID`).
|
|
98
102
|
*/
|
|
99
103
|
export function accessRequestConversationId(
|
|
100
|
-
canonicalAssistantId: string,
|
|
101
104
|
sourceChannel: string,
|
|
102
105
|
actorExternalId: string,
|
|
103
106
|
): string {
|
|
104
|
-
return `access-req-${
|
|
107
|
+
return `access-req-${DAEMON_INTERNAL_ASSISTANT_ID}-${sourceChannel}-${actorExternalId}`;
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
/**
|
|
@@ -126,7 +129,6 @@ export function accessRequestConversationId(
|
|
|
126
129
|
* fail-closed.
|
|
127
130
|
*/
|
|
128
131
|
export async function isApprovalHandshakeInProgress(params: {
|
|
129
|
-
canonicalAssistantId: string;
|
|
130
132
|
sourceChannel: string;
|
|
131
133
|
actorExternalId: string;
|
|
132
134
|
}): Promise<boolean> {
|
|
@@ -134,7 +136,6 @@ export async function isApprovalHandshakeInProgress(params: {
|
|
|
134
136
|
return false;
|
|
135
137
|
}
|
|
136
138
|
const conversationId = accessRequestConversationId(
|
|
137
|
-
params.canonicalAssistantId,
|
|
138
139
|
params.sourceChannel,
|
|
139
140
|
params.actorExternalId,
|
|
140
141
|
);
|
|
@@ -171,7 +172,6 @@ export async function notifyGuardianOfAccessRequest(
|
|
|
171
172
|
params: AccessRequestParams,
|
|
172
173
|
): Promise<AccessRequestResult> {
|
|
173
174
|
const {
|
|
174
|
-
canonicalAssistantId,
|
|
175
175
|
sourceChannel,
|
|
176
176
|
conversationExternalId,
|
|
177
177
|
actorExternalId,
|
|
@@ -219,7 +219,6 @@ export async function notifyGuardianOfAccessRequest(
|
|
|
219
219
|
// from assistant A could be returned for assistant B, allowing the caller
|
|
220
220
|
// to piggyback on A's guardian approval.
|
|
221
221
|
const conversationId = accessRequestConversationId(
|
|
222
|
-
canonicalAssistantId,
|
|
223
222
|
sourceChannel,
|
|
224
223
|
actorExternalId,
|
|
225
224
|
);
|
|
@@ -270,7 +269,6 @@ export async function notifyGuardianOfAccessRequest(
|
|
|
270
269
|
// re-prompting is allowed again.
|
|
271
270
|
if (
|
|
272
271
|
await isApprovalHandshakeInProgress({
|
|
273
|
-
canonicalAssistantId,
|
|
274
272
|
sourceChannel,
|
|
275
273
|
actorExternalId,
|
|
276
274
|
})
|
|
@@ -283,7 +281,7 @@ export async function notifyGuardianOfAccessRequest(
|
|
|
283
281
|
}
|
|
284
282
|
|
|
285
283
|
const senderIdentifier = actorDisplayName || actorUsername || actorExternalId;
|
|
286
|
-
const requestId = `access-req-${
|
|
284
|
+
const requestId = `access-req-${DAEMON_INTERNAL_ASSISTANT_ID}-${sourceChannel}-${actorExternalId}-${Date.now()}`;
|
|
287
285
|
|
|
288
286
|
// Access requests are decisionable: without a bound principal nobody could
|
|
289
287
|
// ever decide them (mirrors the gateway create's integrity guard).
|
|
@@ -461,7 +459,6 @@ export async function maybeNotifyGuardianOfAdmittedContact(
|
|
|
461
459
|
}
|
|
462
460
|
|
|
463
461
|
const conversationId = accessRequestConversationId(
|
|
464
|
-
params.canonicalAssistantId,
|
|
465
462
|
params.sourceChannel,
|
|
466
463
|
params.actorExternalId,
|
|
467
464
|
);
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* Shared types, constants, and utilities used across channel route modules.
|
|
3
3
|
*/
|
|
4
4
|
import type { ChannelId } from "../../channels/types.js";
|
|
5
|
-
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
|
|
6
5
|
import {
|
|
7
6
|
type ApprovalAction,
|
|
8
7
|
type ApprovalDecisionResult,
|
|
@@ -10,11 +9,6 @@ import {
|
|
|
10
9
|
isApprovalAction,
|
|
11
10
|
} from "../channel-approval-types.js";
|
|
12
11
|
|
|
13
|
-
/** Canonicalize assistantId for channel ingress paths. */
|
|
14
|
-
export function canonicalChannelAssistantId(_assistantId: string): string {
|
|
15
|
-
return DAEMON_INTERNAL_ASSISTANT_ID;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
12
|
// ---------------------------------------------------------------------------
|
|
19
13
|
// Actor role
|
|
20
14
|
// ---------------------------------------------------------------------------
|
|
@@ -101,7 +101,6 @@ import {
|
|
|
101
101
|
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
|
|
102
102
|
import { deliverChannelReply } from "../gateway-client.js";
|
|
103
103
|
import { trustContextFromVerdict } from "../trust-verdict-consumer.js";
|
|
104
|
-
import { canonicalChannelAssistantId } from "./channel-route-shared.js";
|
|
105
104
|
import { BadRequestError } from "./errors.js";
|
|
106
105
|
import { handleApprovalInterception } from "./guardian-approval-interception.js";
|
|
107
106
|
import {
|
|
@@ -371,16 +370,6 @@ export async function handleChannelInbound({
|
|
|
371
370
|
throw new BadRequestError("content or attachmentIds is required");
|
|
372
371
|
}
|
|
373
372
|
|
|
374
|
-
// Canonicalize the assistant ID so all DB-facing operations use the
|
|
375
|
-
// consistent 'self' key regardless of what the gateway sent.
|
|
376
|
-
const canonicalAssistantId = canonicalChannelAssistantId(assistantId);
|
|
377
|
-
if (canonicalAssistantId !== assistantId) {
|
|
378
|
-
log.debug(
|
|
379
|
-
{ raw: assistantId, canonical: canonicalAssistantId },
|
|
380
|
-
"Canonicalized channel assistant ID",
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
373
|
// Coerce actorExternalId to a string at the boundary — the field
|
|
385
374
|
// comes from unvalidated JSON and may be a number, object, or other
|
|
386
375
|
// non-string type. Non-string truthy values would throw inside
|
|
@@ -438,7 +427,6 @@ export async function handleChannelInbound({
|
|
|
438
427
|
sourceInterface,
|
|
439
428
|
conversationExternalId,
|
|
440
429
|
externalMessageId,
|
|
441
|
-
canonicalAssistantId,
|
|
442
430
|
rawSenderId,
|
|
443
431
|
canonicalSenderId,
|
|
444
432
|
actorDisplayName: body.actorDisplayName,
|
|
@@ -486,7 +474,6 @@ export async function handleChannelInbound({
|
|
|
486
474
|
rawSenderId,
|
|
487
475
|
sourceChannel,
|
|
488
476
|
conversationExternalId,
|
|
489
|
-
canonicalAssistantId,
|
|
490
477
|
trimmedContent,
|
|
491
478
|
sourceMetadata: body.sourceMetadata,
|
|
492
479
|
actorDisplayName: body.actorDisplayName,
|
|
@@ -839,7 +826,6 @@ export async function handleChannelInbound({
|
|
|
839
826
|
if (isCallbackInteraction) {
|
|
840
827
|
if (floorSenderId) {
|
|
841
828
|
handshakeInProgress = await isApprovalHandshakeInProgress({
|
|
842
|
-
canonicalAssistantId,
|
|
843
829
|
sourceChannel,
|
|
844
830
|
actorExternalId: floorSenderId,
|
|
845
831
|
});
|
|
@@ -847,7 +833,6 @@ export async function handleChannelInbound({
|
|
|
847
833
|
} else {
|
|
848
834
|
try {
|
|
849
835
|
const accessResult = await notifyGuardianOfAccessRequest({
|
|
850
|
-
canonicalAssistantId,
|
|
851
836
|
sourceChannel,
|
|
852
837
|
conversationExternalId,
|
|
853
838
|
actorExternalId: floorSenderId,
|
|
@@ -920,7 +905,7 @@ export async function handleChannelInbound({
|
|
|
920
905
|
const replyPayload: Parameters<typeof deliverChannelReply>[1] = {
|
|
921
906
|
chatId: conversationExternalId,
|
|
922
907
|
text: replyText,
|
|
923
|
-
assistantId:
|
|
908
|
+
assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
924
909
|
};
|
|
925
910
|
if (sourceChannel === "slack" && (canonicalSenderId ?? rawSenderId)) {
|
|
926
911
|
replyPayload.ephemeral = true;
|
|
@@ -982,7 +967,7 @@ export async function handleChannelInbound({
|
|
|
982
967
|
const replyPayload: Parameters<typeof deliverChannelReply>[1] = {
|
|
983
968
|
chatId: conversationExternalId,
|
|
984
969
|
text: DISK_PRESSURE_REMOTE_BLOCK_REPLY,
|
|
985
|
-
assistantId:
|
|
970
|
+
assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
986
971
|
};
|
|
987
972
|
if (sourceChannel === "slack" && (canonicalSenderId ?? rawSenderId)) {
|
|
988
973
|
replyPayload.ephemeral = true;
|
|
@@ -1054,7 +1039,6 @@ export async function handleChannelInbound({
|
|
|
1054
1039
|
isDuplicate: result.duplicate,
|
|
1055
1040
|
commandIntent,
|
|
1056
1041
|
rawSenderId,
|
|
1057
|
-
canonicalAssistantId,
|
|
1058
1042
|
sourceChannel,
|
|
1059
1043
|
conversationExternalId,
|
|
1060
1044
|
eventId: result.eventId,
|
|
@@ -1076,7 +1060,6 @@ export async function handleChannelInbound({
|
|
|
1076
1060
|
callbackData: body.callbackData,
|
|
1077
1061
|
rawSenderId,
|
|
1078
1062
|
canonicalSenderId,
|
|
1079
|
-
canonicalAssistantId,
|
|
1080
1063
|
sourceChannel,
|
|
1081
1064
|
conversationExternalId,
|
|
1082
1065
|
conversationId: result.conversationId,
|
|
@@ -1118,7 +1101,7 @@ export async function handleChannelInbound({
|
|
|
1118
1101
|
actorExternalId: canonicalSenderId ?? rawSenderId,
|
|
1119
1102
|
replyCallbackUrl,
|
|
1120
1103
|
trustCtx,
|
|
1121
|
-
assistantId:
|
|
1104
|
+
assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
1122
1105
|
approvalCopyGenerator,
|
|
1123
1106
|
approvalConversationGenerator,
|
|
1124
1107
|
approvalMessageTs,
|
|
@@ -1214,7 +1197,7 @@ export async function handleChannelInbound({
|
|
|
1214
1197
|
chatId: conversationExternalId,
|
|
1215
1198
|
text: "This approval request has been resolved.",
|
|
1216
1199
|
messageTs: approvalMessageTs,
|
|
1217
|
-
assistantId:
|
|
1200
|
+
assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
1218
1201
|
}).catch((err) => {
|
|
1219
1202
|
log.error(
|
|
1220
1203
|
{ err, conversationId: result.conversationId },
|
|
@@ -1250,7 +1233,6 @@ export async function handleChannelInbound({
|
|
|
1250
1233
|
actorUsername: body.actorUsername,
|
|
1251
1234
|
trustCtx,
|
|
1252
1235
|
replyCallbackUrl,
|
|
1253
|
-
canonicalAssistantId,
|
|
1254
1236
|
});
|
|
1255
1237
|
|
|
1256
1238
|
if (ingressResult.blocked) {
|
|
@@ -1291,7 +1273,6 @@ export async function handleChannelInbound({
|
|
|
1291
1273
|
const admittedSenderId = canonicalSenderId ?? rawSenderId;
|
|
1292
1274
|
if (admittedSenderId) {
|
|
1293
1275
|
void maybeNotifyGuardianOfAdmittedContact({
|
|
1294
|
-
canonicalAssistantId,
|
|
1295
1276
|
sourceChannel,
|
|
1296
1277
|
conversationExternalId,
|
|
1297
1278
|
actorExternalId: admittedSenderId,
|
|
@@ -1492,7 +1473,7 @@ export async function handleChannelInbound({
|
|
|
1492
1473
|
commandIntent,
|
|
1493
1474
|
sourceLanguageCode,
|
|
1494
1475
|
replyCallbackUrl,
|
|
1495
|
-
assistantId:
|
|
1476
|
+
assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
1496
1477
|
approvalCopyGenerator,
|
|
1497
1478
|
chatType: sourceChatType,
|
|
1498
1479
|
clientTimezone: inboundClientTimezone,
|
|
@@ -90,7 +90,6 @@ export interface AclEnforcementParams {
|
|
|
90
90
|
rawSenderId: string | undefined;
|
|
91
91
|
sourceChannel: ChannelId;
|
|
92
92
|
conversationExternalId: string;
|
|
93
|
-
canonicalAssistantId: string;
|
|
94
93
|
trimmedContent: string;
|
|
95
94
|
sourceMetadata: SourceMetadata | undefined;
|
|
96
95
|
actorDisplayName: string | undefined;
|
|
@@ -190,7 +189,6 @@ export async function enforceIngressAcl(
|
|
|
190
189
|
rawSenderId,
|
|
191
190
|
sourceChannel,
|
|
192
191
|
conversationExternalId,
|
|
193
|
-
canonicalAssistantId,
|
|
194
192
|
trimmedContent,
|
|
195
193
|
sourceMetadata,
|
|
196
194
|
actorDisplayName,
|
|
@@ -426,7 +424,6 @@ export async function enforceIngressAcl(
|
|
|
426
424
|
// Still notify the guardian about the access attempt
|
|
427
425
|
try {
|
|
428
426
|
await notifyGuardianOfAccessRequest({
|
|
429
|
-
canonicalAssistantId,
|
|
430
427
|
sourceChannel,
|
|
431
428
|
conversationExternalId,
|
|
432
429
|
actorExternalId: canonicalSenderId ?? rawSenderId,
|
|
@@ -509,7 +506,6 @@ export async function enforceIngressAcl(
|
|
|
509
506
|
if (emailVerifyResult.initiated) {
|
|
510
507
|
try {
|
|
511
508
|
await notifyGuardianOfAccessRequest({
|
|
512
|
-
canonicalAssistantId,
|
|
513
509
|
sourceChannel,
|
|
514
510
|
conversationExternalId,
|
|
515
511
|
actorExternalId: canonicalSenderId ?? rawSenderId,
|
|
@@ -554,14 +550,12 @@ export async function enforceIngressAcl(
|
|
|
554
550
|
let handshakeInProgress = false;
|
|
555
551
|
if (isCallbackInteraction) {
|
|
556
552
|
handshakeInProgress = await isApprovalHandshakeInProgress({
|
|
557
|
-
canonicalAssistantId,
|
|
558
553
|
sourceChannel,
|
|
559
554
|
actorExternalId: (canonicalSenderId ?? rawSenderId)!,
|
|
560
555
|
});
|
|
561
556
|
} else {
|
|
562
557
|
try {
|
|
563
558
|
const accessResult = await notifyGuardianOfAccessRequest({
|
|
564
|
-
canonicalAssistantId,
|
|
565
559
|
sourceChannel,
|
|
566
560
|
conversationExternalId,
|
|
567
561
|
actorExternalId: canonicalSenderId ?? rawSenderId,
|
|
@@ -730,7 +724,6 @@ export async function enforceIngressAcl(
|
|
|
730
724
|
if (slackVerifyResult.initiated) {
|
|
731
725
|
try {
|
|
732
726
|
await notifyGuardianOfAccessRequest({
|
|
733
|
-
canonicalAssistantId,
|
|
734
727
|
sourceChannel,
|
|
735
728
|
conversationExternalId,
|
|
736
729
|
actorExternalId: canonicalSenderId ?? rawSenderId,
|
|
@@ -803,14 +796,12 @@ export async function enforceIngressAcl(
|
|
|
803
796
|
if (!terminallyKeptOut) {
|
|
804
797
|
if (isCallbackInteraction) {
|
|
805
798
|
handshakeInProgress = await isApprovalHandshakeInProgress({
|
|
806
|
-
canonicalAssistantId,
|
|
807
799
|
sourceChannel,
|
|
808
800
|
actorExternalId: (canonicalSenderId ?? rawSenderId)!,
|
|
809
801
|
});
|
|
810
802
|
} else {
|
|
811
803
|
try {
|
|
812
804
|
const accessResult = await notifyGuardianOfAccessRequest({
|
|
813
|
-
canonicalAssistantId,
|
|
814
805
|
sourceChannel,
|
|
815
806
|
conversationExternalId,
|
|
816
807
|
actorExternalId: canonicalSenderId ?? rawSenderId,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import type { ChannelId } from "../../../channels/types.js";
|
|
25
25
|
import { sendTelegramReply } from "../../../messaging/providers/telegram-bot/send.js";
|
|
26
26
|
import { getLogger } from "../../../util/logger.js";
|
|
27
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../assistant-scope.js";
|
|
27
28
|
import { RESEND_COOLDOWN_MS } from "../../verification-outbound-actions.js";
|
|
28
29
|
import {
|
|
29
30
|
composeVerificationText,
|
|
@@ -40,7 +41,6 @@ export interface BootstrapInterceptParams {
|
|
|
40
41
|
isDuplicate: boolean;
|
|
41
42
|
commandIntent: Record<string, unknown> | undefined;
|
|
42
43
|
rawSenderId: string | undefined;
|
|
43
|
-
canonicalAssistantId: string;
|
|
44
44
|
sourceChannel: ChannelId;
|
|
45
45
|
conversationExternalId: string;
|
|
46
46
|
eventId: string;
|
|
@@ -96,7 +96,6 @@ export async function handleBootstrapIntercept(
|
|
|
96
96
|
isDuplicate,
|
|
97
97
|
commandIntent,
|
|
98
98
|
rawSenderId,
|
|
99
|
-
canonicalAssistantId,
|
|
100
99
|
sourceChannel,
|
|
101
100
|
conversationExternalId,
|
|
102
101
|
eventId,
|
|
@@ -208,7 +207,7 @@ export async function handleBootstrapIntercept(
|
|
|
208
207
|
deliverBootstrapVerificationTelegram(
|
|
209
208
|
conversationExternalId,
|
|
210
209
|
telegramBody,
|
|
211
|
-
|
|
210
|
+
DAEMON_INTERNAL_ASSISTANT_ID,
|
|
212
211
|
);
|
|
213
212
|
|
|
214
213
|
// Update delivery tracking (best-effort — the code is already on its way)
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../../../channels/gateway-guardian-requests.js";
|
|
16
16
|
import type { ChannelId } from "../../../channels/types.js";
|
|
17
17
|
import { getLogger } from "../../../util/logger.js";
|
|
18
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../assistant-scope.js";
|
|
18
19
|
import { deliverChannelReply } from "../../gateway-client.js";
|
|
19
20
|
import {
|
|
20
21
|
type GuardianPendingScope,
|
|
@@ -41,7 +42,6 @@ export interface GuardianReplyInterceptParams {
|
|
|
41
42
|
reactedMessageTs?: string;
|
|
42
43
|
rawSenderId: string | undefined;
|
|
43
44
|
canonicalSenderId: string | null;
|
|
44
|
-
canonicalAssistantId: string;
|
|
45
45
|
sourceChannel: ChannelId;
|
|
46
46
|
conversationExternalId: string;
|
|
47
47
|
conversationId: string;
|
|
@@ -77,7 +77,6 @@ export async function handleGuardianReplyIntercept(
|
|
|
77
77
|
reactedMessageTs,
|
|
78
78
|
rawSenderId,
|
|
79
79
|
canonicalSenderId,
|
|
80
|
-
canonicalAssistantId,
|
|
81
80
|
sourceChannel,
|
|
82
81
|
conversationExternalId,
|
|
83
82
|
conversationId,
|
|
@@ -178,7 +177,7 @@ export async function handleGuardianReplyIntercept(
|
|
|
178
177
|
channelDeliveryContext: {
|
|
179
178
|
replyCallbackUrl,
|
|
180
179
|
guardianChatId: conversationExternalId,
|
|
181
|
-
assistantId:
|
|
180
|
+
assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
182
181
|
},
|
|
183
182
|
});
|
|
184
183
|
|
|
@@ -188,7 +187,7 @@ export async function handleGuardianReplyIntercept(
|
|
|
188
187
|
const routerReplyPayload: Parameters<typeof deliverChannelReply>[1] = {
|
|
189
188
|
chatId: conversationExternalId,
|
|
190
189
|
text: routerResult.replyText,
|
|
191
|
-
assistantId:
|
|
190
|
+
assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
192
191
|
};
|
|
193
192
|
// On Slack, send guardian management replies (disambiguation, pending
|
|
194
193
|
// request lists, etc.) as ephemeral so only the guardian sees them.
|
|
@@ -172,7 +172,6 @@ function buildParams(overrides: {
|
|
|
172
172
|
sourceInterface: "slack" as const,
|
|
173
173
|
conversationExternalId: SLACK_CHANNEL_ID,
|
|
174
174
|
externalMessageId: `${SLACK_CHANNEL_ID}:1700000000.1:${msgCounter}`,
|
|
175
|
-
canonicalAssistantId: "assistant-1",
|
|
176
175
|
rawSenderId: overrides.rawSenderId,
|
|
177
176
|
canonicalSenderId: overrides.rawSenderId,
|
|
178
177
|
actorDisplayName: "Reactor",
|
|
@@ -35,7 +35,6 @@ import {
|
|
|
35
35
|
import { markProcessed } from "../../../persistence/delivery-status.js";
|
|
36
36
|
import { upsertBinding } from "../../../persistence/external-conversation-store.js";
|
|
37
37
|
import { getLogger } from "../../../util/logger.js";
|
|
38
|
-
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../assistant-scope.js";
|
|
39
38
|
import type { ApprovalConversationGenerator } from "../../http-types.js";
|
|
40
39
|
import {
|
|
41
40
|
actorTrustContextFromVerdict,
|
|
@@ -100,7 +99,6 @@ export interface ReactionInterceptParams {
|
|
|
100
99
|
sourceInterface: InterfaceId | undefined;
|
|
101
100
|
conversationExternalId: string;
|
|
102
101
|
externalMessageId: string;
|
|
103
|
-
canonicalAssistantId: string;
|
|
104
102
|
rawSenderId: string | undefined;
|
|
105
103
|
canonicalSenderId: string | null;
|
|
106
104
|
actorDisplayName: string | undefined;
|
|
@@ -126,7 +124,6 @@ export async function handleSlackReactionIntercept(
|
|
|
126
124
|
sourceInterface,
|
|
127
125
|
conversationExternalId,
|
|
128
126
|
externalMessageId,
|
|
129
|
-
canonicalAssistantId,
|
|
130
127
|
rawSenderId,
|
|
131
128
|
canonicalSenderId,
|
|
132
129
|
actorDisplayName,
|
|
@@ -215,21 +212,17 @@ export async function handleSlackReactionIntercept(
|
|
|
215
212
|
};
|
|
216
213
|
}
|
|
217
214
|
|
|
218
|
-
// Maintain the conversation binding, matching the message pipeline.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
displayName: actorDisplayName ?? null,
|
|
230
|
-
username: actorUsername ?? null,
|
|
231
|
-
});
|
|
232
|
-
}
|
|
215
|
+
// Maintain the conversation binding, matching the message pipeline.
|
|
216
|
+
upsertBinding({
|
|
217
|
+
conversationId: result.conversationId,
|
|
218
|
+
sourceChannel,
|
|
219
|
+
externalChatId: conversationExternalId,
|
|
220
|
+
externalChatName: slackChannelName,
|
|
221
|
+
externalThreadId: threadTs ?? null,
|
|
222
|
+
externalUserId: canonicalSenderId ?? rawSenderId ?? null,
|
|
223
|
+
displayName: actorDisplayName ?? null,
|
|
224
|
+
username: actorUsername ?? null,
|
|
225
|
+
});
|
|
233
226
|
|
|
234
227
|
// Guardian approval-by-reaction → guardian decision pipeline, exactly like
|
|
235
228
|
// buttons and text replies. Only `reaction:` (added) expresses intent;
|
|
@@ -246,7 +239,6 @@ export async function handleSlackReactionIntercept(
|
|
|
246
239
|
reactedMessageTs,
|
|
247
240
|
rawSenderId,
|
|
248
241
|
canonicalSenderId,
|
|
249
|
-
canonicalAssistantId,
|
|
250
242
|
sourceChannel,
|
|
251
243
|
conversationExternalId,
|
|
252
244
|
conversationId: result.conversationId,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../../../persistence/delivery-crud.js";
|
|
15
15
|
import { checkIngressForSecrets } from "../../../security/secret-ingress.js";
|
|
16
16
|
import { getLogger } from "../../../util/logger.js";
|
|
17
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../assistant-scope.js";
|
|
17
18
|
|
|
18
19
|
const log = getLogger("runtime-http");
|
|
19
20
|
|
|
@@ -36,7 +37,6 @@ export interface SecretIngressCheckParams {
|
|
|
36
37
|
actorUsername: string | undefined;
|
|
37
38
|
trustCtx: TrustContext;
|
|
38
39
|
replyCallbackUrl: string | undefined;
|
|
39
|
-
canonicalAssistantId: string;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface SecretIngressCheckResult {
|
|
@@ -69,7 +69,6 @@ export function runSecretIngressCheck(
|
|
|
69
69
|
actorUsername,
|
|
70
70
|
trustCtx,
|
|
71
71
|
replyCallbackUrl,
|
|
72
|
-
canonicalAssistantId,
|
|
73
72
|
} = params;
|
|
74
73
|
|
|
75
74
|
// Persist the raw payload so dead-lettered events can always be replayed.
|
|
@@ -85,7 +84,7 @@ export function runSecretIngressCheck(
|
|
|
85
84
|
senderUsername: actorUsername,
|
|
86
85
|
trustCtx,
|
|
87
86
|
replyCallbackUrl,
|
|
88
|
-
assistantId:
|
|
87
|
+
assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
89
88
|
});
|
|
90
89
|
|
|
91
90
|
// ── Secret ingress scan ──
|