@vellumai/assistant 0.4.15 → 0.4.17
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/Dockerfile +6 -6
- package/README.md +1 -2
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +383 -254
- package/src/__tests__/call-controller.test.ts +1074 -751
- package/src/__tests__/call-routes-http.test.ts +329 -279
- package/src/__tests__/channel-approval-routes.test.ts +2 -13
- package/src/__tests__/channel-approvals.test.ts +227 -182
- package/src/__tests__/channel-guardian.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +157 -114
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +164 -104
- package/src/__tests__/conversation-routes.test.ts +71 -41
- package/src/__tests__/daemon-server-session-init.test.ts +258 -191
- package/src/__tests__/deterministic-verification-control-plane.test.ts +183 -134
- package/src/__tests__/extract-email.test.ts +42 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +467 -368
- package/src/__tests__/gateway-only-guard.test.ts +54 -55
- package/src/__tests__/gmail-integration.test.ts +48 -46
- package/src/__tests__/guardian-action-followup-executor.test.ts +215 -150
- package/src/__tests__/guardian-outbound-http.test.ts +334 -208
- package/src/__tests__/guardian-routing-invariants.test.ts +680 -613
- package/src/__tests__/guardian-routing-state.test.ts +257 -209
- package/src/__tests__/guardian-verification-voice-binding.test.ts +47 -40
- package/src/__tests__/handle-user-message-secret-resume.test.ts +44 -21
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +269 -195
- package/src/__tests__/inbound-invite-redemption.test.ts +194 -151
- package/src/__tests__/ingress-reconcile.test.ts +184 -142
- package/src/__tests__/non-member-access-request.test.ts +291 -247
- package/src/__tests__/notification-telegram-adapter.test.ts +60 -46
- package/src/__tests__/pairing-concurrent.test.ts +78 -0
- package/src/__tests__/recording-intent-handler.test.ts +422 -291
- package/src/__tests__/runtime-attachment-metadata.test.ts +107 -69
- package/src/__tests__/runtime-events-sse.test.ts +67 -50
- package/src/__tests__/send-endpoint-busy.test.ts +314 -232
- package/src/__tests__/session-approval-overrides.test.ts +93 -91
- package/src/__tests__/sms-messaging-provider.test.ts +74 -47
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +339 -274
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +484 -372
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +261 -239
- package/src/__tests__/trusted-contact-multichannel.test.ts +179 -140
- package/src/__tests__/twilio-config.test.ts +49 -41
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +189 -162
- package/src/__tests__/twilio-routes.test.ts +389 -280
- package/src/calls/call-controller.ts +1 -1
- package/src/calls/guardian-action-sweep.ts +6 -6
- package/src/calls/twilio-routes.ts +2 -4
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +29 -4
- package/src/config/bundled-skills/messaging/SKILL.md +5 -4
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +69 -4
- package/src/config/env.ts +39 -29
- package/src/daemon/handlers/config-inbox.ts +5 -5
- package/src/daemon/handlers/skills.ts +18 -10
- package/src/daemon/ipc-contract/messages.ts +1 -0
- package/src/daemon/ipc-contract/surfaces.ts +7 -1
- package/src/daemon/pairing-store.ts +15 -2
- package/src/daemon/session-agent-loop-handlers.ts +5 -0
- package/src/daemon/session-agent-loop.ts +1 -1
- package/src/daemon/session-process.ts +1 -1
- package/src/daemon/session-slash.ts +4 -4
- package/src/daemon/session-surfaces.ts +42 -2
- package/src/runtime/auth/token-service.ts +95 -45
- package/src/runtime/channel-retry-sweep.ts +2 -2
- package/src/runtime/http-server.ts +8 -7
- package/src/runtime/http-types.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +1 -1
- package/src/runtime/routes/guardian-bootstrap-routes.ts +3 -2
- package/src/runtime/routes/guardian-expiry-sweep.ts +5 -5
- package/src/runtime/routes/pairing-routes.ts +4 -1
- package/src/sequence/reply-matcher.ts +14 -4
- package/src/skills/frontmatter.ts +9 -6
- package/src/tools/ui-surface/definitions.ts +3 -1
- package/src/util/platform.ts +0 -12
- package/docs/architecture/http-token-refresh.md +0 -274
|
@@ -1,73 +1,84 @@
|
|
|
1
|
-
import * as net from
|
|
1
|
+
import * as net from "node:net";
|
|
2
2
|
|
|
3
|
-
import { beforeEach, describe, expect, mock, test } from
|
|
3
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
5
|
+
mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true }));
|
|
6
|
+
|
|
7
|
+
import type { HandlerContext } from "../daemon/handlers.js";
|
|
8
|
+
import type {
|
|
9
|
+
ConfirmationResponse,
|
|
10
|
+
UserMessage,
|
|
11
|
+
} from "../daemon/ipc-contract.js";
|
|
12
|
+
import type { ServerMessage } from "../daemon/ipc-protocol.js";
|
|
13
|
+
import { DebouncerMap } from "../util/debounce.js";
|
|
9
14
|
|
|
10
15
|
const routeGuardianReplyMock = mock(async () => ({
|
|
11
16
|
consumed: false,
|
|
12
17
|
decisionApplied: false,
|
|
13
|
-
type:
|
|
18
|
+
type: "not_consumed" as const,
|
|
14
19
|
})) as any;
|
|
15
20
|
const createCanonicalGuardianRequestMock = mock(() => ({
|
|
16
|
-
id:
|
|
21
|
+
id: "canonical-id",
|
|
17
22
|
}));
|
|
18
|
-
const generateCanonicalRequestCodeMock = mock(() =>
|
|
19
|
-
const listPendingByDestinationMock = mock(
|
|
23
|
+
const generateCanonicalRequestCodeMock = mock(() => "ABC123");
|
|
24
|
+
const listPendingByDestinationMock = mock(
|
|
25
|
+
() => [] as Array<{ id: string; kind?: string }>,
|
|
26
|
+
);
|
|
20
27
|
const listCanonicalMock = mock(() => [] as Array<{ id: string }>);
|
|
21
|
-
const resolveCanonicalGuardianRequestMock = mock(
|
|
28
|
+
const resolveCanonicalGuardianRequestMock = mock(
|
|
29
|
+
() => null as { id: string } | null,
|
|
30
|
+
);
|
|
22
31
|
const getByConversationMock = mock(
|
|
23
|
-
() =>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
() =>
|
|
33
|
+
[] as Array<{
|
|
34
|
+
requestId: string;
|
|
35
|
+
kind: "confirmation" | "secret";
|
|
36
|
+
session?: unknown;
|
|
37
|
+
}>,
|
|
28
38
|
);
|
|
29
39
|
const registerMock = mock(() => {});
|
|
30
40
|
const resolveMock = mock(() => undefined as unknown);
|
|
31
|
-
const addMessageMock = mock(async () => ({ id:
|
|
41
|
+
const addMessageMock = mock(async () => ({ id: "persisted-message-id" }));
|
|
32
42
|
const getConfigMock = mock(() => ({
|
|
33
43
|
daemon: { standaloneRecording: false },
|
|
34
44
|
secretDetection: { customPatterns: [], entropyThreshold: 3.5 },
|
|
35
45
|
}));
|
|
36
46
|
|
|
37
|
-
mock.module(
|
|
47
|
+
mock.module("../runtime/guardian-reply-router.js", () => ({
|
|
38
48
|
routeGuardianReply: routeGuardianReplyMock,
|
|
39
49
|
}));
|
|
40
50
|
|
|
41
|
-
mock.module(
|
|
51
|
+
mock.module("../memory/canonical-guardian-store.js", () => ({
|
|
42
52
|
createCanonicalGuardianRequest: createCanonicalGuardianRequestMock,
|
|
43
53
|
generateCanonicalRequestCode: generateCanonicalRequestCodeMock,
|
|
44
|
-
listPendingCanonicalGuardianRequestsByDestinationConversation:
|
|
54
|
+
listPendingCanonicalGuardianRequestsByDestinationConversation:
|
|
55
|
+
listPendingByDestinationMock,
|
|
45
56
|
listCanonicalGuardianRequests: listCanonicalMock,
|
|
46
57
|
resolveCanonicalGuardianRequest: resolveCanonicalGuardianRequestMock,
|
|
47
58
|
}));
|
|
48
59
|
|
|
49
|
-
mock.module(
|
|
60
|
+
mock.module("../runtime/pending-interactions.js", () => ({
|
|
50
61
|
register: registerMock,
|
|
51
62
|
getByConversation: getByConversationMock,
|
|
52
63
|
resolve: resolveMock,
|
|
53
64
|
}));
|
|
54
65
|
|
|
55
|
-
mock.module(
|
|
66
|
+
mock.module("../memory/conversation-store.js", () => ({
|
|
56
67
|
addMessage: addMessageMock,
|
|
57
68
|
}));
|
|
58
69
|
|
|
59
|
-
mock.module(
|
|
70
|
+
mock.module("../config/loader.js", () => ({
|
|
60
71
|
getConfig: getConfigMock,
|
|
61
72
|
}));
|
|
62
73
|
|
|
63
|
-
mock.module(
|
|
74
|
+
mock.module("../daemon/approval-generators.js", () => ({
|
|
64
75
|
createApprovalConversationGenerator: () => async () => ({
|
|
65
|
-
disposition:
|
|
66
|
-
replyText:
|
|
76
|
+
disposition: "keep_pending",
|
|
77
|
+
replyText: "pending",
|
|
67
78
|
}),
|
|
68
79
|
}));
|
|
69
80
|
|
|
70
|
-
mock.module(
|
|
81
|
+
mock.module("../util/logger.js", () => ({
|
|
71
82
|
getLogger: () => ({
|
|
72
83
|
info: () => {},
|
|
73
84
|
warn: () => {},
|
|
@@ -82,7 +93,10 @@ mock.module('../util/logger.js', () => ({
|
|
|
82
93
|
}),
|
|
83
94
|
}));
|
|
84
95
|
|
|
85
|
-
import {
|
|
96
|
+
import {
|
|
97
|
+
handleConfirmationResponse,
|
|
98
|
+
handleUserMessage,
|
|
99
|
+
} from "../daemon/handlers/sessions.js";
|
|
86
100
|
|
|
87
101
|
interface TestSession {
|
|
88
102
|
messages: Array<{ role: string; content: unknown[] }>;
|
|
@@ -93,20 +107,30 @@ interface TestSession {
|
|
|
93
107
|
hasAnyPendingConfirmation: () => boolean;
|
|
94
108
|
getQueueDepth: () => number;
|
|
95
109
|
denyAllPendingConfirmations: () => void;
|
|
96
|
-
enqueueMessage: (...args: unknown[]) => {
|
|
110
|
+
enqueueMessage: (...args: unknown[]) => {
|
|
111
|
+
queued: boolean;
|
|
112
|
+
rejected?: boolean;
|
|
113
|
+
requestId: string;
|
|
114
|
+
};
|
|
97
115
|
traceEmitter: { emit: (...args: unknown[]) => void };
|
|
98
116
|
setTurnChannelContext: (ctx: unknown) => void;
|
|
99
117
|
setTurnInterfaceContext: (ctx: unknown) => void;
|
|
100
118
|
setAssistantId: (assistantId: string) => void;
|
|
101
119
|
setGuardianContext: (ctx: unknown) => void;
|
|
102
120
|
setCommandIntent: (intent: unknown) => void;
|
|
103
|
-
updateClient: (
|
|
121
|
+
updateClient: (
|
|
122
|
+
sendToClient: (msg: ServerMessage) => void,
|
|
123
|
+
hasNoClient?: boolean,
|
|
124
|
+
) => void;
|
|
104
125
|
emitActivityState: (...args: unknown[]) => void;
|
|
105
126
|
emitConfirmationStateChanged: (...args: unknown[]) => void;
|
|
106
127
|
processMessage: (...args: unknown[]) => Promise<string>;
|
|
107
128
|
}
|
|
108
129
|
|
|
109
|
-
function createContext(session: TestSession): {
|
|
130
|
+
function createContext(session: TestSession): {
|
|
131
|
+
ctx: HandlerContext;
|
|
132
|
+
sent: ServerMessage[];
|
|
133
|
+
} {
|
|
110
134
|
const sent: ServerMessage[] = [];
|
|
111
135
|
const ctx: HandlerContext = {
|
|
112
136
|
sessions: new Map(),
|
|
@@ -120,7 +144,9 @@ function createContext(session: TestSession): { ctx: HandlerContext; sent: Serve
|
|
|
120
144
|
suppressConfigReload: false,
|
|
121
145
|
setSuppressConfigReload: () => {},
|
|
122
146
|
updateConfigFingerprint: () => {},
|
|
123
|
-
send: (_socket, msg) => {
|
|
147
|
+
send: (_socket, msg) => {
|
|
148
|
+
sent.push(msg);
|
|
149
|
+
},
|
|
124
150
|
broadcast: () => {},
|
|
125
151
|
clearAllSessions: () => 0,
|
|
126
152
|
getOrCreateSession: async () => session as any,
|
|
@@ -131,11 +157,11 @@ function createContext(session: TestSession): { ctx: HandlerContext; sent: Serve
|
|
|
131
157
|
|
|
132
158
|
function makeMessage(content: string): UserMessage {
|
|
133
159
|
return {
|
|
134
|
-
type:
|
|
135
|
-
sessionId:
|
|
160
|
+
type: "user_message",
|
|
161
|
+
sessionId: "conv-1",
|
|
136
162
|
content,
|
|
137
|
-
channel:
|
|
138
|
-
interface:
|
|
163
|
+
channel: "vellum",
|
|
164
|
+
interface: "macos",
|
|
139
165
|
};
|
|
140
166
|
}
|
|
141
167
|
|
|
@@ -149,7 +175,7 @@ function makeSession(overrides: Partial<TestSession> = {}): TestSession {
|
|
|
149
175
|
hasAnyPendingConfirmation: () => true,
|
|
150
176
|
getQueueDepth: () => 0,
|
|
151
177
|
denyAllPendingConfirmations: mock(() => {}),
|
|
152
|
-
enqueueMessage: mock(() => ({ queued: true, requestId:
|
|
178
|
+
enqueueMessage: mock(() => ({ queued: true, requestId: "queued-id" })),
|
|
153
179
|
traceEmitter: { emit: () => {} },
|
|
154
180
|
setTurnChannelContext: () => {},
|
|
155
181
|
setTurnInterfaceContext: () => {},
|
|
@@ -159,12 +185,12 @@ function makeSession(overrides: Partial<TestSession> = {}): TestSession {
|
|
|
159
185
|
updateClient: () => {},
|
|
160
186
|
emitActivityState: () => {},
|
|
161
187
|
emitConfirmationStateChanged: () => {},
|
|
162
|
-
processMessage: async () =>
|
|
188
|
+
processMessage: async () => "msg-id",
|
|
163
189
|
...overrides,
|
|
164
190
|
};
|
|
165
191
|
}
|
|
166
192
|
|
|
167
|
-
describe(
|
|
193
|
+
describe("handleUserMessage pending-confirmation reply interception", () => {
|
|
168
194
|
beforeEach(() => {
|
|
169
195
|
routeGuardianReplyMock.mockClear();
|
|
170
196
|
createCanonicalGuardianRequestMock.mockClear();
|
|
@@ -179,79 +205,90 @@ describe('handleUserMessage pending-confirmation reply interception', () => {
|
|
|
179
205
|
getConfigMock.mockClear();
|
|
180
206
|
});
|
|
181
207
|
|
|
182
|
-
test(
|
|
183
|
-
listPendingByDestinationMock.mockReturnValue([
|
|
184
|
-
|
|
208
|
+
test("consumes decision replies before auto-deny", async () => {
|
|
209
|
+
listPendingByDestinationMock.mockReturnValue([
|
|
210
|
+
{ id: "req-1", kind: "tool_approval" },
|
|
211
|
+
]);
|
|
212
|
+
listCanonicalMock.mockReturnValue([{ id: "req-1" }]);
|
|
185
213
|
routeGuardianReplyMock.mockResolvedValue({
|
|
186
214
|
consumed: true,
|
|
187
215
|
decisionApplied: true,
|
|
188
|
-
type:
|
|
189
|
-
requestId:
|
|
216
|
+
type: "canonical_decision_applied",
|
|
217
|
+
requestId: "req-1",
|
|
190
218
|
});
|
|
191
219
|
|
|
192
220
|
const session = makeSession();
|
|
193
221
|
const { ctx, sent } = createContext(session);
|
|
194
222
|
|
|
195
|
-
await handleUserMessage(makeMessage(
|
|
223
|
+
await handleUserMessage(makeMessage("go for it"), {} as net.Socket, ctx);
|
|
196
224
|
|
|
197
225
|
expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
|
|
198
|
-
const routeCall = (routeGuardianReplyMock as any).mock
|
|
199
|
-
|
|
200
|
-
expect(
|
|
201
|
-
expect(
|
|
226
|
+
const routeCall = (routeGuardianReplyMock as any).mock
|
|
227
|
+
.calls[0][0] as Record<string, unknown>;
|
|
228
|
+
expect(routeCall.messageText).toBe("go for it");
|
|
229
|
+
expect(typeof routeCall.approvalConversationGenerator).toBe("function");
|
|
230
|
+
expect((session.denyAllPendingConfirmations as any).mock.calls.length).toBe(
|
|
231
|
+
0,
|
|
232
|
+
);
|
|
202
233
|
expect((session.enqueueMessage as any).mock.calls.length).toBe(0);
|
|
203
234
|
expect(session.messages).toHaveLength(2);
|
|
204
|
-
expect(session.messages[0]?.role).toBe(
|
|
205
|
-
expect(session.messages[1]?.role).toBe(
|
|
235
|
+
expect(session.messages[0]?.role).toBe("user");
|
|
236
|
+
expect(session.messages[1]?.role).toBe("assistant");
|
|
206
237
|
expect(addMessageMock).toHaveBeenCalledTimes(2);
|
|
207
238
|
expect(addMessageMock).toHaveBeenCalledWith(
|
|
208
|
-
|
|
209
|
-
|
|
239
|
+
"conv-1",
|
|
240
|
+
"user",
|
|
210
241
|
expect.any(String),
|
|
211
242
|
expect.objectContaining({
|
|
212
|
-
userMessageChannel:
|
|
213
|
-
assistantMessageChannel:
|
|
214
|
-
userMessageInterface:
|
|
215
|
-
assistantMessageInterface:
|
|
216
|
-
provenanceTrustClass:
|
|
243
|
+
userMessageChannel: "vellum",
|
|
244
|
+
assistantMessageChannel: "vellum",
|
|
245
|
+
userMessageInterface: "macos",
|
|
246
|
+
assistantMessageInterface: "macos",
|
|
247
|
+
provenanceTrustClass: "guardian",
|
|
217
248
|
}),
|
|
218
249
|
);
|
|
219
250
|
expect(addMessageMock).toHaveBeenCalledWith(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
expect.stringContaining(
|
|
251
|
+
"conv-1",
|
|
252
|
+
"assistant",
|
|
253
|
+
expect.stringContaining("Decision applied."),
|
|
223
254
|
expect.objectContaining({
|
|
224
|
-
userMessageChannel:
|
|
225
|
-
assistantMessageChannel:
|
|
226
|
-
userMessageInterface:
|
|
227
|
-
assistantMessageInterface:
|
|
228
|
-
provenanceTrustClass:
|
|
255
|
+
userMessageChannel: "vellum",
|
|
256
|
+
assistantMessageChannel: "vellum",
|
|
257
|
+
userMessageInterface: "macos",
|
|
258
|
+
assistantMessageInterface: "macos",
|
|
259
|
+
provenanceTrustClass: "guardian",
|
|
229
260
|
}),
|
|
230
261
|
);
|
|
231
262
|
expect(sent.map((msg) => msg.type)).toEqual([
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
263
|
+
"message_queued",
|
|
264
|
+
"message_dequeued",
|
|
265
|
+
"assistant_text_delta",
|
|
266
|
+
"message_request_complete",
|
|
236
267
|
]);
|
|
237
268
|
const assistantDelta = sent.find(
|
|
238
|
-
(msg): msg is Extract<ServerMessage, { type:
|
|
269
|
+
(msg): msg is Extract<ServerMessage, { type: "assistant_text_delta" }> =>
|
|
270
|
+
msg.type === "assistant_text_delta",
|
|
239
271
|
);
|
|
240
|
-
expect(assistantDelta?.text).toBe(
|
|
272
|
+
expect(assistantDelta?.text).toBe("Decision applied.");
|
|
241
273
|
const requestComplete = sent.find(
|
|
242
|
-
(
|
|
274
|
+
(
|
|
275
|
+
msg,
|
|
276
|
+
): msg is Extract<ServerMessage, { type: "message_request_complete" }> =>
|
|
277
|
+
msg.type === "message_request_complete",
|
|
243
278
|
);
|
|
244
279
|
expect(requestComplete?.runStillActive).toBe(false);
|
|
245
280
|
});
|
|
246
281
|
|
|
247
|
-
test(
|
|
248
|
-
listPendingByDestinationMock.mockReturnValue([
|
|
249
|
-
|
|
282
|
+
test("consumes decision replies even when queue depth is non-zero", async () => {
|
|
283
|
+
listPendingByDestinationMock.mockReturnValue([
|
|
284
|
+
{ id: "req-1", kind: "tool_approval" },
|
|
285
|
+
]);
|
|
286
|
+
listCanonicalMock.mockReturnValue([{ id: "req-1" }]);
|
|
250
287
|
routeGuardianReplyMock.mockResolvedValue({
|
|
251
288
|
consumed: true,
|
|
252
289
|
decisionApplied: true,
|
|
253
|
-
type:
|
|
254
|
-
requestId:
|
|
290
|
+
type: "canonical_decision_applied",
|
|
291
|
+
requestId: "req-1",
|
|
255
292
|
});
|
|
256
293
|
|
|
257
294
|
const session = makeSession({
|
|
@@ -259,274 +296,311 @@ describe('handleUserMessage pending-confirmation reply interception', () => {
|
|
|
259
296
|
});
|
|
260
297
|
const { ctx } = createContext(session);
|
|
261
298
|
|
|
262
|
-
await handleUserMessage(makeMessage(
|
|
299
|
+
await handleUserMessage(makeMessage("approve"), {} as net.Socket, ctx);
|
|
263
300
|
|
|
264
301
|
expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
|
|
265
|
-
expect((session.denyAllPendingConfirmations as any).mock.calls.length).toBe(
|
|
302
|
+
expect((session.denyAllPendingConfirmations as any).mock.calls.length).toBe(
|
|
303
|
+
0,
|
|
304
|
+
);
|
|
266
305
|
expect((session.enqueueMessage as any).mock.calls.length).toBe(0);
|
|
267
306
|
});
|
|
268
307
|
|
|
269
|
-
test(
|
|
270
|
-
listPendingByDestinationMock.mockReturnValue([
|
|
271
|
-
|
|
308
|
+
test("does not mutate in-memory history while processing", async () => {
|
|
309
|
+
listPendingByDestinationMock.mockReturnValue([
|
|
310
|
+
{ id: "req-1", kind: "tool_approval" },
|
|
311
|
+
]);
|
|
312
|
+
listCanonicalMock.mockReturnValue([{ id: "req-1" }]);
|
|
272
313
|
routeGuardianReplyMock.mockResolvedValue({
|
|
273
314
|
consumed: true,
|
|
274
315
|
decisionApplied: true,
|
|
275
|
-
type:
|
|
276
|
-
requestId:
|
|
316
|
+
type: "canonical_decision_applied",
|
|
317
|
+
requestId: "req-1",
|
|
277
318
|
});
|
|
278
319
|
|
|
279
320
|
const session = makeSession({ isProcessing: () => true });
|
|
280
321
|
const { ctx, sent } = createContext(session);
|
|
281
322
|
|
|
282
|
-
await handleUserMessage(makeMessage(
|
|
323
|
+
await handleUserMessage(makeMessage("approve"), {} as net.Socket, ctx);
|
|
283
324
|
|
|
284
325
|
expect(addMessageMock).toHaveBeenCalledTimes(2);
|
|
285
326
|
expect(session.messages).toHaveLength(0);
|
|
286
327
|
// assistant_text_delta must NOT be sent when the session is processing —
|
|
287
328
|
// it would contaminate the agent's in-flight streaming message on the client.
|
|
288
|
-
expect(sent.some((msg) => msg.type ===
|
|
329
|
+
expect(sent.some((msg) => msg.type === "assistant_text_delta")).toBe(false);
|
|
289
330
|
expect(sent.map((msg) => msg.type)).toEqual([
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
331
|
+
"message_queued",
|
|
332
|
+
"message_dequeued",
|
|
333
|
+
"message_request_complete",
|
|
293
334
|
]);
|
|
294
335
|
const requestComplete = sent.find(
|
|
295
|
-
(
|
|
336
|
+
(
|
|
337
|
+
msg,
|
|
338
|
+
): msg is Extract<ServerMessage, { type: "message_request_complete" }> =>
|
|
339
|
+
msg.type === "message_request_complete",
|
|
296
340
|
);
|
|
297
341
|
expect(requestComplete?.runStillActive).toBe(true);
|
|
298
342
|
});
|
|
299
343
|
|
|
300
|
-
test(
|
|
301
|
-
listPendingByDestinationMock.mockReturnValue([
|
|
302
|
-
|
|
344
|
+
test("nl keep_pending falls back to existing auto-deny + queue behavior", async () => {
|
|
345
|
+
listPendingByDestinationMock.mockReturnValue([
|
|
346
|
+
{ id: "req-1", kind: "tool_approval" },
|
|
347
|
+
]);
|
|
348
|
+
listCanonicalMock.mockReturnValue([{ id: "req-1" }]);
|
|
303
349
|
routeGuardianReplyMock.mockResolvedValue({
|
|
304
350
|
consumed: true,
|
|
305
351
|
decisionApplied: false,
|
|
306
|
-
type:
|
|
307
|
-
requestId:
|
|
308
|
-
replyText:
|
|
352
|
+
type: "nl_keep_pending",
|
|
353
|
+
requestId: "req-1",
|
|
354
|
+
replyText: "Need clarification",
|
|
309
355
|
});
|
|
310
356
|
|
|
311
357
|
const session = makeSession();
|
|
312
358
|
const { ctx, sent } = createContext(session);
|
|
313
359
|
|
|
314
|
-
await handleUserMessage(
|
|
360
|
+
await handleUserMessage(
|
|
361
|
+
makeMessage("what does that do?"),
|
|
362
|
+
{} as net.Socket,
|
|
363
|
+
ctx,
|
|
364
|
+
);
|
|
315
365
|
|
|
316
366
|
expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
|
|
317
|
-
expect((session.denyAllPendingConfirmations as any).mock.calls.length).toBe(
|
|
367
|
+
expect((session.denyAllPendingConfirmations as any).mock.calls.length).toBe(
|
|
368
|
+
1,
|
|
369
|
+
);
|
|
318
370
|
expect((session.enqueueMessage as any).mock.calls.length).toBe(1);
|
|
319
371
|
expect(session.messages).toHaveLength(0);
|
|
320
372
|
expect(addMessageMock).toHaveBeenCalledTimes(0);
|
|
321
|
-
expect(sent.some((msg) => msg.type ===
|
|
322
|
-
expect(sent.some((msg) => msg.type ===
|
|
373
|
+
expect(sent.some((msg) => msg.type === "message_queued")).toBe(true);
|
|
374
|
+
expect(sent.some((msg) => msg.type === "message_dequeued")).toBe(false);
|
|
323
375
|
});
|
|
324
376
|
|
|
325
|
-
test(
|
|
377
|
+
test("routes only live pending confirmation request ids", async () => {
|
|
326
378
|
const session = makeSession({
|
|
327
|
-
hasPendingConfirmation: (requestId: string) => requestId ===
|
|
379
|
+
hasPendingConfirmation: (requestId: string) => requestId === "req-live",
|
|
328
380
|
});
|
|
329
381
|
|
|
330
382
|
getByConversationMock.mockReturnValue([
|
|
331
|
-
{ requestId:
|
|
332
|
-
{
|
|
383
|
+
{ requestId: "req-stale", kind: "confirmation", session: {} },
|
|
384
|
+
{
|
|
385
|
+
requestId: "req-live",
|
|
386
|
+
kind: "confirmation",
|
|
387
|
+
session: session as unknown,
|
|
388
|
+
},
|
|
333
389
|
]);
|
|
334
390
|
listPendingByDestinationMock.mockReturnValue([
|
|
335
|
-
{ id:
|
|
336
|
-
{ id:
|
|
391
|
+
{ id: "req-stale", kind: "tool_approval" },
|
|
392
|
+
{ id: "req-live", kind: "tool_approval" },
|
|
337
393
|
]);
|
|
338
394
|
listCanonicalMock.mockReturnValue([
|
|
339
|
-
{ id:
|
|
340
|
-
{ id:
|
|
395
|
+
{ id: "req-stale" },
|
|
396
|
+
{ id: "req-live" },
|
|
341
397
|
]);
|
|
342
398
|
routeGuardianReplyMock.mockResolvedValue({
|
|
343
399
|
consumed: false,
|
|
344
400
|
decisionApplied: false,
|
|
345
|
-
type:
|
|
401
|
+
type: "not_consumed",
|
|
346
402
|
});
|
|
347
403
|
|
|
348
404
|
const { ctx } = createContext(session);
|
|
349
|
-
await handleUserMessage(makeMessage(
|
|
405
|
+
await handleUserMessage(makeMessage("allow"), {} as net.Socket, ctx);
|
|
350
406
|
|
|
351
407
|
expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
|
|
352
|
-
const routeCall = (routeGuardianReplyMock as any).mock
|
|
353
|
-
|
|
408
|
+
const routeCall = (routeGuardianReplyMock as any).mock
|
|
409
|
+
.calls[0][0] as Record<string, unknown>;
|
|
410
|
+
expect(routeCall.pendingRequestIds).toEqual(["req-live"]);
|
|
354
411
|
// Auto-deny clears matching confirmation entries from pending-interactions
|
|
355
412
|
// so stale IDs are not reused as routing candidates. Only the live
|
|
356
413
|
// session-scoped interaction should be resolved.
|
|
357
414
|
expect(resolveMock).toHaveBeenCalledTimes(1);
|
|
358
|
-
expect(resolveMock).toHaveBeenCalledWith(
|
|
415
|
+
expect(resolveMock).toHaveBeenCalledWith("req-live");
|
|
359
416
|
expect(resolveCanonicalGuardianRequestMock).toHaveBeenCalledTimes(1);
|
|
360
417
|
expect(resolveCanonicalGuardianRequestMock).toHaveBeenCalledWith(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
{ status:
|
|
418
|
+
"req-live",
|
|
419
|
+
"pending",
|
|
420
|
+
{ status: "denied" },
|
|
364
421
|
);
|
|
365
422
|
});
|
|
366
423
|
|
|
367
|
-
test(
|
|
424
|
+
test("registers IPC confirmation events for NL approval routing", async () => {
|
|
368
425
|
const session = makeSession({
|
|
369
426
|
hasAnyPendingConfirmation: () => false,
|
|
370
|
-
enqueueMessage: mock(() => ({ queued: false, requestId:
|
|
427
|
+
enqueueMessage: mock(() => ({ queued: false, requestId: "direct-id" })),
|
|
371
428
|
processMessage: async (_content, _attachments, onEvent) => {
|
|
372
429
|
(onEvent as (msg: ServerMessage) => void)({
|
|
373
|
-
type:
|
|
374
|
-
requestId:
|
|
375
|
-
toolName:
|
|
376
|
-
input: { phone_number:
|
|
377
|
-
riskLevel:
|
|
378
|
-
executionTarget:
|
|
430
|
+
type: "confirmation_request",
|
|
431
|
+
requestId: "req-confirm-1",
|
|
432
|
+
toolName: "call_start",
|
|
433
|
+
input: { phone_number: "+18084436762" },
|
|
434
|
+
riskLevel: "high",
|
|
435
|
+
executionTarget: "host",
|
|
379
436
|
allowlistOptions: [],
|
|
380
437
|
scopeOptions: [],
|
|
381
438
|
persistentDecisionsAllowed: false,
|
|
382
439
|
} as ServerMessage);
|
|
383
|
-
return
|
|
440
|
+
return "msg-id";
|
|
384
441
|
},
|
|
385
442
|
});
|
|
386
443
|
const { ctx, sent } = createContext(session);
|
|
387
444
|
|
|
388
|
-
await handleUserMessage(
|
|
445
|
+
await handleUserMessage(
|
|
446
|
+
makeMessage("please call now"),
|
|
447
|
+
{} as net.Socket,
|
|
448
|
+
ctx,
|
|
449
|
+
);
|
|
389
450
|
|
|
390
451
|
expect(registerMock).toHaveBeenCalledTimes(1);
|
|
391
452
|
expect(registerMock).toHaveBeenCalledWith(
|
|
392
|
-
|
|
453
|
+
"req-confirm-1",
|
|
393
454
|
expect.objectContaining({
|
|
394
|
-
conversationId:
|
|
395
|
-
kind:
|
|
455
|
+
conversationId: "conv-1",
|
|
456
|
+
kind: "confirmation",
|
|
396
457
|
session,
|
|
397
458
|
confirmationDetails: expect.objectContaining({
|
|
398
|
-
toolName:
|
|
399
|
-
riskLevel:
|
|
400
|
-
executionTarget:
|
|
459
|
+
toolName: "call_start",
|
|
460
|
+
riskLevel: "high",
|
|
461
|
+
executionTarget: "host",
|
|
401
462
|
}),
|
|
402
463
|
}),
|
|
403
464
|
);
|
|
404
465
|
expect(createCanonicalGuardianRequestMock).toHaveBeenCalledTimes(1);
|
|
405
466
|
expect(createCanonicalGuardianRequestMock).toHaveBeenCalledWith(
|
|
406
467
|
expect.objectContaining({
|
|
407
|
-
id:
|
|
408
|
-
kind:
|
|
409
|
-
sourceType:
|
|
410
|
-
sourceChannel:
|
|
411
|
-
conversationId:
|
|
412
|
-
toolName:
|
|
413
|
-
status:
|
|
414
|
-
requestCode:
|
|
468
|
+
id: "req-confirm-1",
|
|
469
|
+
kind: "tool_approval",
|
|
470
|
+
sourceType: "desktop",
|
|
471
|
+
sourceChannel: "vellum",
|
|
472
|
+
conversationId: "conv-1",
|
|
473
|
+
toolName: "call_start",
|
|
474
|
+
status: "pending",
|
|
475
|
+
requestCode: "ABC123",
|
|
415
476
|
}),
|
|
416
477
|
);
|
|
417
|
-
expect(sent.some((event) => event.type ===
|
|
478
|
+
expect(sent.some((event) => event.type === "confirmation_request")).toBe(
|
|
479
|
+
true,
|
|
480
|
+
);
|
|
418
481
|
});
|
|
419
482
|
|
|
420
|
-
test(
|
|
483
|
+
test("registers IPC confirmation events emitted via session sender (prompter path)", async () => {
|
|
421
484
|
let currentSender: (msg: ServerMessage) => void = () => {};
|
|
422
485
|
const session = makeSession({
|
|
423
486
|
hasAnyPendingConfirmation: () => false,
|
|
424
|
-
enqueueMessage: mock(() => ({ queued: false, requestId:
|
|
487
|
+
enqueueMessage: mock(() => ({ queued: false, requestId: "direct-id" })),
|
|
425
488
|
updateClient: (sendToClient: (msg: ServerMessage) => void) => {
|
|
426
489
|
currentSender = sendToClient;
|
|
427
490
|
},
|
|
428
491
|
processMessage: async () => {
|
|
429
492
|
currentSender({
|
|
430
|
-
type:
|
|
431
|
-
requestId:
|
|
432
|
-
toolName:
|
|
433
|
-
input: { phone_number:
|
|
434
|
-
riskLevel:
|
|
435
|
-
executionTarget:
|
|
493
|
+
type: "confirmation_request",
|
|
494
|
+
requestId: "req-prompter-1",
|
|
495
|
+
toolName: "call_start",
|
|
496
|
+
input: { phone_number: "+18084436762" },
|
|
497
|
+
riskLevel: "high",
|
|
498
|
+
executionTarget: "host",
|
|
436
499
|
allowlistOptions: [],
|
|
437
500
|
scopeOptions: [],
|
|
438
501
|
persistentDecisionsAllowed: false,
|
|
439
502
|
} as ServerMessage);
|
|
440
|
-
return
|
|
503
|
+
return "msg-id";
|
|
441
504
|
},
|
|
442
505
|
});
|
|
443
506
|
const { ctx, sent } = createContext(session);
|
|
444
507
|
|
|
445
|
-
await handleUserMessage(
|
|
508
|
+
await handleUserMessage(
|
|
509
|
+
makeMessage("please call now"),
|
|
510
|
+
{} as net.Socket,
|
|
511
|
+
ctx,
|
|
512
|
+
);
|
|
446
513
|
|
|
447
514
|
expect(registerMock).toHaveBeenCalledWith(
|
|
448
|
-
|
|
515
|
+
"req-prompter-1",
|
|
449
516
|
expect.objectContaining({
|
|
450
|
-
conversationId:
|
|
451
|
-
kind:
|
|
517
|
+
conversationId: "conv-1",
|
|
518
|
+
kind: "confirmation",
|
|
452
519
|
session,
|
|
453
520
|
}),
|
|
454
521
|
);
|
|
455
522
|
expect(createCanonicalGuardianRequestMock).toHaveBeenCalledWith(
|
|
456
523
|
expect.objectContaining({
|
|
457
|
-
id:
|
|
458
|
-
kind:
|
|
459
|
-
sourceType:
|
|
460
|
-
sourceChannel:
|
|
461
|
-
conversationId:
|
|
524
|
+
id: "req-prompter-1",
|
|
525
|
+
kind: "tool_approval",
|
|
526
|
+
sourceType: "desktop",
|
|
527
|
+
sourceChannel: "vellum",
|
|
528
|
+
conversationId: "conv-1",
|
|
462
529
|
}),
|
|
463
530
|
);
|
|
464
|
-
expect(sent.some((event) => event.type ===
|
|
531
|
+
expect(sent.some((event) => event.type === "confirmation_request")).toBe(
|
|
532
|
+
true,
|
|
533
|
+
);
|
|
465
534
|
});
|
|
466
535
|
|
|
467
|
-
test(
|
|
536
|
+
test("syncs canonical status to approved for IPC allow decisions", () => {
|
|
468
537
|
const session = {
|
|
469
|
-
hasPendingConfirmation: (requestId: string) =>
|
|
538
|
+
hasPendingConfirmation: (requestId: string) =>
|
|
539
|
+
requestId === "req-confirm-allow",
|
|
470
540
|
handleConfirmationResponse: mock(() => {}),
|
|
471
541
|
};
|
|
472
542
|
const { ctx } = createContext(makeSession());
|
|
473
|
-
ctx.sessions.set(
|
|
543
|
+
ctx.sessions.set("conv-1", session as any);
|
|
474
544
|
|
|
475
545
|
const msg: ConfirmationResponse = {
|
|
476
|
-
type:
|
|
477
|
-
requestId:
|
|
478
|
-
decision:
|
|
546
|
+
type: "confirmation_response",
|
|
547
|
+
requestId: "req-confirm-allow",
|
|
548
|
+
decision: "always_allow",
|
|
479
549
|
};
|
|
480
550
|
|
|
481
551
|
handleConfirmationResponse(msg, {} as net.Socket, ctx);
|
|
482
552
|
|
|
483
|
-
expect((session.handleConfirmationResponse as any).mock.calls.length).toBe(
|
|
553
|
+
expect((session.handleConfirmationResponse as any).mock.calls.length).toBe(
|
|
554
|
+
1,
|
|
555
|
+
);
|
|
484
556
|
expect((session.handleConfirmationResponse as any).mock.calls[0]).toEqual([
|
|
485
|
-
|
|
486
|
-
|
|
557
|
+
"req-confirm-allow",
|
|
558
|
+
"always_allow",
|
|
487
559
|
undefined,
|
|
488
560
|
undefined,
|
|
489
561
|
undefined,
|
|
490
|
-
{ source:
|
|
562
|
+
{ source: "button" },
|
|
491
563
|
]);
|
|
492
564
|
expect(resolveCanonicalGuardianRequestMock).toHaveBeenCalledWith(
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
{ status:
|
|
565
|
+
"req-confirm-allow",
|
|
566
|
+
"pending",
|
|
567
|
+
{ status: "approved" },
|
|
496
568
|
);
|
|
497
|
-
expect(resolveMock).toHaveBeenCalledWith(
|
|
569
|
+
expect(resolveMock).toHaveBeenCalledWith("req-confirm-allow");
|
|
498
570
|
});
|
|
499
571
|
|
|
500
|
-
test(
|
|
572
|
+
test("syncs canonical status to denied for IPC deny decisions in CU sessions", () => {
|
|
501
573
|
const cuSession = {
|
|
502
|
-
hasPendingConfirmation: (requestId: string) =>
|
|
574
|
+
hasPendingConfirmation: (requestId: string) =>
|
|
575
|
+
requestId === "req-confirm-deny",
|
|
503
576
|
handleConfirmationResponse: mock(() => {}),
|
|
504
577
|
};
|
|
505
|
-
const { ctx } = createContext(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
578
|
+
const { ctx } = createContext(
|
|
579
|
+
makeSession({
|
|
580
|
+
hasPendingConfirmation: () => false,
|
|
581
|
+
}),
|
|
582
|
+
);
|
|
583
|
+
ctx.cuSessions.set("cu-1", cuSession as any);
|
|
509
584
|
|
|
510
585
|
const msg: ConfirmationResponse = {
|
|
511
|
-
type:
|
|
512
|
-
requestId:
|
|
513
|
-
decision:
|
|
586
|
+
type: "confirmation_response",
|
|
587
|
+
requestId: "req-confirm-deny",
|
|
588
|
+
decision: "always_deny",
|
|
514
589
|
};
|
|
515
590
|
|
|
516
591
|
handleConfirmationResponse(msg, {} as net.Socket, ctx);
|
|
517
592
|
|
|
518
|
-
expect(
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
undefined,
|
|
523
|
-
|
|
524
|
-
]);
|
|
593
|
+
expect(
|
|
594
|
+
(cuSession.handleConfirmationResponse as any).mock.calls.length,
|
|
595
|
+
).toBe(1);
|
|
596
|
+
expect((cuSession.handleConfirmationResponse as any).mock.calls[0]).toEqual(
|
|
597
|
+
["req-confirm-deny", "always_deny", undefined, undefined],
|
|
598
|
+
);
|
|
525
599
|
expect(resolveCanonicalGuardianRequestMock).toHaveBeenCalledWith(
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
{ status:
|
|
600
|
+
"req-confirm-deny",
|
|
601
|
+
"pending",
|
|
602
|
+
{ status: "denied" },
|
|
529
603
|
);
|
|
530
|
-
expect(resolveMock).toHaveBeenCalledWith(
|
|
604
|
+
expect(resolveMock).toHaveBeenCalledWith("req-confirm-deny");
|
|
531
605
|
});
|
|
532
606
|
});
|