@vellumai/assistant 0.4.16 → 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__/call-controller.test.ts +1074 -751
- package/src/__tests__/call-routes-http.test.ts +329 -279
- package/src/__tests__/channel-approval-routes.test.ts +0 -11
- 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__/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/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 +11 -7
- package/src/config/env.ts +39 -29
- 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/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-surfaces.ts +42 -2
- package/src/runtime/auth/token-service.ts +74 -47
- package/src/sequence/reply-matcher.ts +10 -6
- package/src/skills/frontmatter.ts +9 -6
- package/src/tools/ui-surface/definitions.ts +2 -1
- package/src/util/platform.ts +0 -12
- package/docs/architecture/http-token-refresh.md +0 -274
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type * as net from
|
|
1
|
+
import type * 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
|
-
|
|
5
|
+
mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true }));
|
|
6
|
+
|
|
7
|
+
import * as pendingInteractions from "../runtime/pending-interactions.js";
|
|
6
8
|
|
|
7
9
|
interface MockMemoryPolicy {
|
|
8
10
|
scopeId: string;
|
|
@@ -11,20 +13,20 @@ interface MockMemoryPolicy {
|
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
const MOCK_DEFAULT_MEMORY_POLICY: MockMemoryPolicy = {
|
|
14
|
-
scopeId:
|
|
16
|
+
scopeId: "default",
|
|
15
17
|
includeDefaultFallback: false,
|
|
16
18
|
strictSideEffects: false,
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
const conversation = {
|
|
20
|
-
id:
|
|
21
|
-
title:
|
|
22
|
+
id: "conv-1",
|
|
23
|
+
title: "Test Conversation",
|
|
22
24
|
updatedAt: Date.now(),
|
|
23
25
|
totalInputTokens: 0,
|
|
24
26
|
totalOutputTokens: 0,
|
|
25
27
|
totalEstimatedCost: 0,
|
|
26
|
-
threadType:
|
|
27
|
-
memoryScopeId:
|
|
28
|
+
threadType: "standard" as string,
|
|
29
|
+
memoryScopeId: "default" as string,
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
let lastCreatedWorkingDir: string | undefined;
|
|
@@ -44,8 +46,12 @@ class MockSession {
|
|
|
44
46
|
public updateClientCalls = 0;
|
|
45
47
|
public ensureActorScopedHistoryCalls = 0;
|
|
46
48
|
public lastUpdateClientHasNoClient: boolean | undefined;
|
|
47
|
-
public lastUpdateClientSender:
|
|
48
|
-
|
|
49
|
+
public lastUpdateClientSender:
|
|
50
|
+
| ((msg: Record<string, unknown>) => void)
|
|
51
|
+
| undefined;
|
|
52
|
+
public lastRunAgentLoopOptions:
|
|
53
|
+
| { skipPreMessageRollback?: boolean; isInteractive?: boolean }
|
|
54
|
+
| undefined;
|
|
49
55
|
public updateClientHistory: Array<{ hasNoClient: boolean }> = [];
|
|
50
56
|
public setSandboxOverrideCalls = 0;
|
|
51
57
|
private stale = false;
|
|
@@ -75,7 +81,10 @@ class MockSession {
|
|
|
75
81
|
this.ensureActorScopedHistoryCalls += 1;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
updateClient(
|
|
84
|
+
updateClient(
|
|
85
|
+
sender?: (msg: Record<string, unknown>) => void,
|
|
86
|
+
hasNoClient = false,
|
|
87
|
+
): void {
|
|
79
88
|
this.updateClientCalls += 1;
|
|
80
89
|
this.lastUpdateClientSender = sender;
|
|
81
90
|
this.lastUpdateClientHasNoClient = hasNoClient;
|
|
@@ -141,7 +150,7 @@ class MockSession {
|
|
|
141
150
|
|
|
142
151
|
persistUserMessage(): string {
|
|
143
152
|
this.processing = true;
|
|
144
|
-
return
|
|
153
|
+
return "msg-1";
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
async runAgentLoop(
|
|
@@ -173,48 +182,49 @@ class MockSession {
|
|
|
173
182
|
|
|
174
183
|
// Mock child_process to prevent getScreenDimensions() from running osascript on Linux CI
|
|
175
184
|
// where AppKit/NSScreen is not available and the execSync call would fail.
|
|
176
|
-
mock.module(
|
|
177
|
-
execSync: () =>
|
|
178
|
-
execFileSync: () =>
|
|
185
|
+
mock.module("node:child_process", () => ({
|
|
186
|
+
execSync: () => "1920x1080",
|
|
187
|
+
execFileSync: () => "",
|
|
179
188
|
}));
|
|
180
189
|
|
|
181
|
-
mock.module(
|
|
182
|
-
getLogger: () =>
|
|
183
|
-
|
|
184
|
-
|
|
190
|
+
mock.module("../util/logger.js", () => ({
|
|
191
|
+
getLogger: () =>
|
|
192
|
+
new Proxy({} as Record<string, unknown>, {
|
|
193
|
+
get: () => () => {},
|
|
194
|
+
}),
|
|
185
195
|
}));
|
|
186
196
|
|
|
187
|
-
mock.module(
|
|
188
|
-
getSocketPath: () =>
|
|
189
|
-
getDataDir: () =>
|
|
190
|
-
getSandboxWorkingDir: () =>
|
|
197
|
+
mock.module("../util/platform.js", () => ({
|
|
198
|
+
getSocketPath: () => "/tmp/test.sock",
|
|
199
|
+
getDataDir: () => "/tmp",
|
|
200
|
+
getSandboxWorkingDir: () => "/tmp/workspace",
|
|
191
201
|
}));
|
|
192
202
|
|
|
193
|
-
mock.module(
|
|
194
|
-
getProvider: () => ({ name:
|
|
195
|
-
getFailoverProvider: () => ({ name:
|
|
203
|
+
mock.module("../providers/registry.js", () => ({
|
|
204
|
+
getProvider: () => ({ name: "mock-provider" }),
|
|
205
|
+
getFailoverProvider: () => ({ name: "mock-provider" }),
|
|
196
206
|
initializeProviders: () => {},
|
|
197
207
|
}));
|
|
198
208
|
|
|
199
|
-
mock.module(
|
|
209
|
+
mock.module("../providers/ratelimit.js", () => ({
|
|
200
210
|
RateLimitProvider: class {
|
|
201
211
|
constructor(..._args: unknown[]) {}
|
|
202
212
|
},
|
|
203
213
|
}));
|
|
204
214
|
|
|
205
|
-
mock.module(
|
|
215
|
+
mock.module("../config/loader.js", () => ({
|
|
206
216
|
getConfig: () => ({
|
|
207
217
|
ui: {},
|
|
208
|
-
|
|
209
|
-
provider:
|
|
210
|
-
providerOrder: [
|
|
218
|
+
|
|
219
|
+
provider: "mock-provider",
|
|
220
|
+
providerOrder: ["mock-provider"],
|
|
211
221
|
maxTokens: 4096,
|
|
212
222
|
thinking: false,
|
|
213
223
|
contextWindow: {
|
|
214
224
|
maxInputTokens: 100000,
|
|
215
225
|
thresholdTokens: 80000,
|
|
216
226
|
preserveRecentMessages: 6,
|
|
217
|
-
summaryModel:
|
|
227
|
+
summaryModel: "mock-model",
|
|
218
228
|
maxSummaryTokens: 512,
|
|
219
229
|
},
|
|
220
230
|
rateLimit: {
|
|
@@ -230,68 +240,82 @@ mock.module('../config/loader.js', () => ({
|
|
|
230
240
|
invalidateConfigCache: () => {},
|
|
231
241
|
}));
|
|
232
242
|
|
|
233
|
-
mock.module(
|
|
234
|
-
buildSystemPrompt: () =>
|
|
243
|
+
mock.module("../config/system-prompt.js", () => ({
|
|
244
|
+
buildSystemPrompt: () => "system prompt",
|
|
235
245
|
}));
|
|
236
246
|
|
|
237
|
-
mock.module(
|
|
247
|
+
mock.module("../permissions/trust-store.js", () => ({
|
|
238
248
|
clearCache: () => {},
|
|
239
249
|
}));
|
|
240
250
|
|
|
241
|
-
mock.module(
|
|
251
|
+
mock.module("../security/secret-allowlist.js", () => ({
|
|
242
252
|
resetAllowlist: () => {},
|
|
243
253
|
}));
|
|
244
254
|
|
|
245
|
-
mock.module(
|
|
255
|
+
mock.module("../memory/external-conversation-store.js", () => ({
|
|
246
256
|
getBindingsForConversations: () => new Map(),
|
|
247
257
|
}));
|
|
248
258
|
|
|
249
|
-
mock.module(
|
|
259
|
+
mock.module("../memory/conversation-attention-store.js", () => ({
|
|
250
260
|
getAttentionStateByConversationIds: () => new Map(),
|
|
251
261
|
recordAttentionSignal: () => {},
|
|
252
262
|
recordConversationSeenSignal: () => {},
|
|
253
263
|
}));
|
|
254
264
|
|
|
255
|
-
mock.module(
|
|
256
|
-
generateCanonicalRequestCode: () =>
|
|
265
|
+
mock.module("../memory/canonical-guardian-store.js", () => ({
|
|
266
|
+
generateCanonicalRequestCode: () => "mock-code-0000",
|
|
257
267
|
createCanonicalGuardianRequest: (params: Record<string, unknown>) => {
|
|
258
268
|
lastCanonicalGuardianCreateParams = params;
|
|
259
|
-
return { requestCode:
|
|
269
|
+
return { requestCode: "mock-code-0000", status: "pending" };
|
|
260
270
|
},
|
|
261
|
-
submitCanonicalRequest: () => ({
|
|
271
|
+
submitCanonicalRequest: () => ({
|
|
272
|
+
requestCode: "mock-code-0000",
|
|
273
|
+
status: "pending",
|
|
274
|
+
}),
|
|
262
275
|
getCanonicalRequest: () => null,
|
|
263
276
|
resolveCanonicalRequest: () => false,
|
|
264
277
|
listPendingCanonicalRequests: () => [],
|
|
265
278
|
}));
|
|
266
279
|
|
|
267
|
-
mock.module(
|
|
280
|
+
mock.module("../memory/conversation-store.js", () => ({
|
|
268
281
|
setConversationOriginChannelIfUnset: () => {},
|
|
269
282
|
updateConversationContextWindow: () => {},
|
|
270
283
|
deleteMessageById: () => {},
|
|
271
284
|
updateConversationTitle: () => {},
|
|
272
285
|
updateConversationUsage: () => {},
|
|
273
|
-
addMessage: () => ({ id:
|
|
274
|
-
provenanceFromGuardianContext: () => ({
|
|
286
|
+
addMessage: () => ({ id: "mock-msg-id" }),
|
|
287
|
+
provenanceFromGuardianContext: () => ({
|
|
288
|
+
source: "user",
|
|
289
|
+
guardianContext: undefined,
|
|
290
|
+
}),
|
|
275
291
|
getConversationOriginInterface: () => null,
|
|
276
292
|
getConversationOriginChannel: () => null,
|
|
277
293
|
getLatestConversation: () => conversation,
|
|
278
|
-
createConversation: (
|
|
294
|
+
createConversation: (
|
|
295
|
+
titleOrOpts?: string | { title?: string; threadType?: string },
|
|
296
|
+
) => {
|
|
279
297
|
lastCreateConversationArgs = titleOrOpts;
|
|
280
298
|
// Derive threadType and memoryScopeId from input, mirroring real implementation
|
|
281
|
-
const opts =
|
|
282
|
-
|
|
299
|
+
const opts =
|
|
300
|
+
typeof titleOrOpts === "string"
|
|
301
|
+
? { title: titleOrOpts }
|
|
302
|
+
: (titleOrOpts ?? {});
|
|
303
|
+
const threadType = opts.threadType ?? "standard";
|
|
283
304
|
conversation.threadType = threadType;
|
|
284
|
-
conversation.memoryScopeId =
|
|
305
|
+
conversation.memoryScopeId =
|
|
306
|
+
threadType === "private" ? `private:${conversation.id}` : "default";
|
|
285
307
|
return conversation;
|
|
286
308
|
},
|
|
287
|
-
getConversation: (id: string) =>
|
|
309
|
+
getConversation: (id: string) =>
|
|
310
|
+
id === conversation.id ? conversation : null,
|
|
288
311
|
getConversationThreadType: (id: string) => {
|
|
289
|
-
if (id === conversation.id)
|
|
290
|
-
|
|
312
|
+
if (id === conversation.id)
|
|
313
|
+
return conversation.threadType === "private" ? "private" : "standard";
|
|
314
|
+
return "standard";
|
|
291
315
|
},
|
|
292
316
|
getConversationMemoryScopeId: (id: string) => {
|
|
293
317
|
if (id === conversation.id) return conversation.memoryScopeId;
|
|
294
|
-
return
|
|
318
|
+
return "default";
|
|
295
319
|
},
|
|
296
320
|
getMessages: () => [],
|
|
297
321
|
listConversations: () => [conversation],
|
|
@@ -299,25 +323,33 @@ mock.module('../memory/conversation-store.js', () => ({
|
|
|
299
323
|
getDisplayMetaForConversations: () => new Map(),
|
|
300
324
|
}));
|
|
301
325
|
|
|
302
|
-
mock.module(
|
|
303
|
-
bridgeConfirmationRequestToGuardian: () => ({
|
|
326
|
+
mock.module("../runtime/confirmation-request-guardian-bridge.js", () => ({
|
|
327
|
+
bridgeConfirmationRequestToGuardian: () => ({
|
|
328
|
+
skipped: true,
|
|
329
|
+
reason: "not_trusted_contact",
|
|
330
|
+
}),
|
|
304
331
|
}));
|
|
305
332
|
|
|
306
|
-
mock.module(
|
|
333
|
+
mock.module("../daemon/session.js", () => ({
|
|
307
334
|
Session: MockSession,
|
|
308
335
|
DEFAULT_MEMORY_POLICY: MOCK_DEFAULT_MEMORY_POLICY,
|
|
309
336
|
}));
|
|
310
337
|
|
|
311
|
-
import { DaemonServer } from
|
|
338
|
+
import { DaemonServer } from "../daemon/server.js";
|
|
312
339
|
|
|
313
340
|
type DaemonServerTestAccess = {
|
|
314
341
|
sendInitialSession: (socket: net.Socket) => Promise<void>;
|
|
315
|
-
dispatchMessage: (
|
|
342
|
+
dispatchMessage: (
|
|
343
|
+
msg: { type: string; [key: string]: unknown },
|
|
344
|
+
socket: net.Socket,
|
|
345
|
+
) => void;
|
|
316
346
|
sessions: Map<string, MockSession>;
|
|
317
347
|
socketToSession: Map<net.Socket, string>;
|
|
318
348
|
};
|
|
319
349
|
|
|
320
|
-
function asDaemonServerTestAccess(
|
|
350
|
+
function asDaemonServerTestAccess(
|
|
351
|
+
server: DaemonServer,
|
|
352
|
+
): DaemonServerTestAccess {
|
|
321
353
|
return server as unknown as DaemonServerTestAccess;
|
|
322
354
|
}
|
|
323
355
|
|
|
@@ -337,16 +369,16 @@ function createFakeSocket() {
|
|
|
337
369
|
|
|
338
370
|
function decodeMessages(writes: string[]): Array<Record<string, unknown>> {
|
|
339
371
|
return writes
|
|
340
|
-
.flatMap((chunk) => chunk.split(
|
|
372
|
+
.flatMap((chunk) => chunk.split("\n"))
|
|
341
373
|
.filter((line) => line.length > 0)
|
|
342
374
|
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
343
375
|
}
|
|
344
376
|
|
|
345
|
-
describe(
|
|
377
|
+
describe("DaemonServer initial session hydration", () => {
|
|
346
378
|
beforeEach(() => {
|
|
347
379
|
conversation.updatedAt = Date.now();
|
|
348
|
-
conversation.threadType =
|
|
349
|
-
conversation.memoryScopeId =
|
|
380
|
+
conversation.threadType = "standard";
|
|
381
|
+
conversation.memoryScopeId = "default";
|
|
350
382
|
lastCreatedWorkingDir = undefined;
|
|
351
383
|
lastCreatedMemoryPolicy = undefined;
|
|
352
384
|
lastCreateConversationArgs = undefined;
|
|
@@ -356,25 +388,28 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
356
388
|
pendingInteractions.clear();
|
|
357
389
|
});
|
|
358
390
|
|
|
359
|
-
test(
|
|
391
|
+
test("hydrates latest session before session_info so undo works after reconnect", async () => {
|
|
360
392
|
const server = new DaemonServer();
|
|
361
393
|
const internal = asDaemonServerTestAccess(server);
|
|
362
394
|
const { socket, writes } = createFakeSocket();
|
|
363
395
|
|
|
364
396
|
await internal.sendInitialSession(socket);
|
|
365
|
-
internal.dispatchMessage(
|
|
397
|
+
internal.dispatchMessage(
|
|
398
|
+
{ type: "undo", sessionId: conversation.id },
|
|
399
|
+
socket,
|
|
400
|
+
);
|
|
366
401
|
|
|
367
402
|
const messages = decodeMessages(writes);
|
|
368
|
-
const sessionInfo = messages.find((msg) => msg.type ===
|
|
369
|
-
const undoComplete = messages.find((msg) => msg.type ===
|
|
370
|
-
const error = messages.find((msg) => msg.type ===
|
|
403
|
+
const sessionInfo = messages.find((msg) => msg.type === "session_info");
|
|
404
|
+
const undoComplete = messages.find((msg) => msg.type === "undo_complete");
|
|
405
|
+
const error = messages.find((msg) => msg.type === "error");
|
|
371
406
|
|
|
372
407
|
expect(sessionInfo).toBeDefined();
|
|
373
408
|
expect(undoComplete).toBeDefined();
|
|
374
409
|
expect(error).toBeUndefined();
|
|
375
410
|
});
|
|
376
411
|
|
|
377
|
-
test(
|
|
412
|
+
test("does not rebind existing session client during initial handshake", async () => {
|
|
378
413
|
const server = new DaemonServer();
|
|
379
414
|
const internal = asDaemonServerTestAccess(server);
|
|
380
415
|
const existingSession = new MockSession(conversation.id);
|
|
@@ -387,17 +422,17 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
387
422
|
expect(internal.socketToSession.size).toBe(0);
|
|
388
423
|
});
|
|
389
424
|
|
|
390
|
-
test(
|
|
425
|
+
test("creates sessions with sandbox working dir by default", async () => {
|
|
391
426
|
const server = new DaemonServer();
|
|
392
427
|
const internal = asDaemonServerTestAccess(server);
|
|
393
428
|
const { socket } = createFakeSocket();
|
|
394
429
|
|
|
395
430
|
await internal.sendInitialSession(socket);
|
|
396
431
|
|
|
397
|
-
expect(lastCreatedWorkingDir).toBe(
|
|
432
|
+
expect(lastCreatedWorkingDir).toBe("/tmp/workspace");
|
|
398
433
|
});
|
|
399
434
|
|
|
400
|
-
test(
|
|
435
|
+
test("ignores deprecated sandbox_set runtime override messages", async () => {
|
|
401
436
|
const server = new DaemonServer();
|
|
402
437
|
const internal = asDaemonServerTestAccess(server);
|
|
403
438
|
const { socket } = createFakeSocket();
|
|
@@ -407,13 +442,13 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
407
442
|
expect(session).toBeDefined();
|
|
408
443
|
expect(session!.setSandboxOverrideCalls).toBe(0);
|
|
409
444
|
|
|
410
|
-
internal.dispatchMessage({ type:
|
|
445
|
+
internal.dispatchMessage({ type: "sandbox_set", enabled: false }, socket);
|
|
411
446
|
|
|
412
447
|
expect(session!.setSandboxOverrideCalls).toBe(0);
|
|
413
448
|
});
|
|
414
449
|
|
|
415
|
-
test(
|
|
416
|
-
conversation.threadType =
|
|
450
|
+
test("sendInitialSession includes threadType in session_info", async () => {
|
|
451
|
+
conversation.threadType = "private";
|
|
417
452
|
const server = new DaemonServer();
|
|
418
453
|
const internal = asDaemonServerTestAccess(server);
|
|
419
454
|
const { socket, writes } = createFakeSocket();
|
|
@@ -421,13 +456,13 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
421
456
|
await internal.sendInitialSession(socket);
|
|
422
457
|
|
|
423
458
|
const messages = decodeMessages(writes);
|
|
424
|
-
const sessionInfo = messages.find((msg) => msg.type ===
|
|
459
|
+
const sessionInfo = messages.find((msg) => msg.type === "session_info");
|
|
425
460
|
expect(sessionInfo).toBeDefined();
|
|
426
|
-
expect(sessionInfo!.threadType).toBe(
|
|
461
|
+
expect(sessionInfo!.threadType).toBe("private");
|
|
427
462
|
});
|
|
428
463
|
|
|
429
|
-
test(
|
|
430
|
-
conversation.threadType =
|
|
464
|
+
test("sendInitialSession includes standard threadType by default", async () => {
|
|
465
|
+
conversation.threadType = "standard";
|
|
431
466
|
const server = new DaemonServer();
|
|
432
467
|
const internal = asDaemonServerTestAccess(server);
|
|
433
468
|
const { socket, writes } = createFakeSocket();
|
|
@@ -435,13 +470,13 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
435
470
|
await internal.sendInitialSession(socket);
|
|
436
471
|
|
|
437
472
|
const messages = decodeMessages(writes);
|
|
438
|
-
const sessionInfo = messages.find((msg) => msg.type ===
|
|
473
|
+
const sessionInfo = messages.find((msg) => msg.type === "session_info");
|
|
439
474
|
expect(sessionInfo).toBeDefined();
|
|
440
|
-
expect(sessionInfo!.threadType).toBe(
|
|
475
|
+
expect(sessionInfo!.threadType).toBe("standard");
|
|
441
476
|
});
|
|
442
477
|
|
|
443
|
-
test(
|
|
444
|
-
conversation.threadType =
|
|
478
|
+
test("session_switch includes threadType in session_info", async () => {
|
|
479
|
+
conversation.threadType = "private";
|
|
445
480
|
const server = new DaemonServer();
|
|
446
481
|
const internal = asDaemonServerTestAccess(server);
|
|
447
482
|
const { socket, writes } = createFakeSocket();
|
|
@@ -450,65 +485,73 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
450
485
|
await internal.sendInitialSession(socket);
|
|
451
486
|
writes.length = 0;
|
|
452
487
|
|
|
453
|
-
internal.dispatchMessage(
|
|
488
|
+
internal.dispatchMessage(
|
|
489
|
+
{ type: "session_switch", sessionId: conversation.id },
|
|
490
|
+
socket,
|
|
491
|
+
);
|
|
454
492
|
// Allow async handler to complete
|
|
455
493
|
await new Promise((r) => setTimeout(r, 50));
|
|
456
494
|
|
|
457
495
|
const messages = decodeMessages(writes);
|
|
458
|
-
const sessionInfo = messages.find((msg) => msg.type ===
|
|
496
|
+
const sessionInfo = messages.find((msg) => msg.type === "session_info");
|
|
459
497
|
expect(sessionInfo).toBeDefined();
|
|
460
|
-
expect(sessionInfo!.threadType).toBe(
|
|
498
|
+
expect(sessionInfo!.threadType).toBe("private");
|
|
461
499
|
});
|
|
462
500
|
|
|
463
|
-
test(
|
|
501
|
+
test("session_create includes threadType in session_info response", async () => {
|
|
464
502
|
// conversation.threadType starts as 'standard' from beforeEach — the mock
|
|
465
503
|
// createConversation must derive 'private' from the IPC request input.
|
|
466
504
|
const server = new DaemonServer();
|
|
467
505
|
const internal = asDaemonServerTestAccess(server);
|
|
468
506
|
const { socket, writes } = createFakeSocket();
|
|
469
507
|
|
|
470
|
-
internal.dispatchMessage(
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
508
|
+
internal.dispatchMessage(
|
|
509
|
+
{
|
|
510
|
+
type: "session_create",
|
|
511
|
+
title: "Thread-type test",
|
|
512
|
+
threadType: "private",
|
|
513
|
+
},
|
|
514
|
+
socket,
|
|
515
|
+
);
|
|
475
516
|
// Allow async handler to complete
|
|
476
517
|
await new Promise((r) => setTimeout(r, 50));
|
|
477
518
|
|
|
478
519
|
// Verify createConversation was called with the threadType from the request
|
|
479
520
|
expect(lastCreateConversationArgs).toEqual({
|
|
480
|
-
title:
|
|
481
|
-
threadType:
|
|
521
|
+
title: "Thread-type test",
|
|
522
|
+
threadType: "private",
|
|
482
523
|
});
|
|
483
524
|
|
|
484
525
|
const messages = decodeMessages(writes);
|
|
485
|
-
const sessionInfo = messages.find((msg) => msg.type ===
|
|
526
|
+
const sessionInfo = messages.find((msg) => msg.type === "session_info");
|
|
486
527
|
expect(sessionInfo).toBeDefined();
|
|
487
|
-
expect(sessionInfo!.threadType).toBe(
|
|
528
|
+
expect(sessionInfo!.threadType).toBe("private");
|
|
488
529
|
});
|
|
489
530
|
|
|
490
|
-
test(
|
|
491
|
-
conversation.threadType =
|
|
531
|
+
test("session_list includes threadType on each session row", async () => {
|
|
532
|
+
conversation.threadType = "private";
|
|
492
533
|
const server = new DaemonServer();
|
|
493
534
|
const internal = asDaemonServerTestAccess(server);
|
|
494
535
|
const { socket, writes } = createFakeSocket();
|
|
495
536
|
|
|
496
|
-
internal.dispatchMessage({ type:
|
|
537
|
+
internal.dispatchMessage({ type: "session_list" }, socket);
|
|
497
538
|
|
|
498
539
|
const messages = decodeMessages(writes);
|
|
499
|
-
const listResponse = messages.find(
|
|
540
|
+
const listResponse = messages.find(
|
|
541
|
+
(msg) => msg.type === "session_list_response",
|
|
542
|
+
);
|
|
500
543
|
expect(listResponse).toBeDefined();
|
|
501
544
|
|
|
502
545
|
const sessions = listResponse!.sessions as Array<Record<string, unknown>>;
|
|
503
546
|
expect(sessions.length).toBeGreaterThan(0);
|
|
504
547
|
for (const session of sessions) {
|
|
505
|
-
expect(session.threadType).toBe(
|
|
548
|
+
expect(session.threadType).toBe("private");
|
|
506
549
|
}
|
|
507
550
|
});
|
|
508
551
|
|
|
509
|
-
test(
|
|
510
|
-
conversation.threadType =
|
|
511
|
-
conversation.memoryScopeId =
|
|
552
|
+
test("session for private conversation derives strict memory policy", async () => {
|
|
553
|
+
conversation.threadType = "private";
|
|
554
|
+
conversation.memoryScopeId = "private:conv-1";
|
|
512
555
|
const server = new DaemonServer();
|
|
513
556
|
const internal = asDaemonServerTestAccess(server);
|
|
514
557
|
const { socket } = createFakeSocket();
|
|
@@ -518,15 +561,15 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
518
561
|
const session = internal.sessions.get(conversation.id);
|
|
519
562
|
expect(session).toBeDefined();
|
|
520
563
|
expect(session!.memoryPolicy).toEqual({
|
|
521
|
-
scopeId:
|
|
564
|
+
scopeId: "private:conv-1",
|
|
522
565
|
includeDefaultFallback: true,
|
|
523
566
|
strictSideEffects: true,
|
|
524
567
|
});
|
|
525
568
|
});
|
|
526
569
|
|
|
527
|
-
test(
|
|
528
|
-
conversation.threadType =
|
|
529
|
-
conversation.memoryScopeId =
|
|
570
|
+
test("session for standard conversation uses default memory policy", async () => {
|
|
571
|
+
conversation.threadType = "standard";
|
|
572
|
+
conversation.memoryScopeId = "default";
|
|
530
573
|
const server = new DaemonServer();
|
|
531
574
|
const internal = asDaemonServerTestAccess(server);
|
|
532
575
|
const { socket } = createFakeSocket();
|
|
@@ -538,10 +581,10 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
538
581
|
expect(session!.memoryPolicy).toEqual(MOCK_DEFAULT_MEMORY_POLICY);
|
|
539
582
|
});
|
|
540
583
|
|
|
541
|
-
test(
|
|
584
|
+
test("session_switch to private conversation derives correct policy on fresh session", async () => {
|
|
542
585
|
// Start with standard conversation
|
|
543
|
-
conversation.threadType =
|
|
544
|
-
conversation.memoryScopeId =
|
|
586
|
+
conversation.threadType = "standard";
|
|
587
|
+
conversation.memoryScopeId = "default";
|
|
545
588
|
const server = new DaemonServer();
|
|
546
589
|
const internal = asDaemonServerTestAccess(server);
|
|
547
590
|
const { socket } = createFakeSocket();
|
|
@@ -549,8 +592,8 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
549
592
|
await internal.sendInitialSession(socket);
|
|
550
593
|
|
|
551
594
|
// Now switch the conversation metadata to private before the switch
|
|
552
|
-
conversation.threadType =
|
|
553
|
-
conversation.memoryScopeId =
|
|
595
|
+
conversation.threadType = "private";
|
|
596
|
+
conversation.memoryScopeId = "private:conv-1";
|
|
554
597
|
|
|
555
598
|
// Evict the existing session so the switch recreates it
|
|
556
599
|
const existingSession = internal.sessions.get(conversation.id);
|
|
@@ -558,116 +601,134 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
558
601
|
existingSession.markStale();
|
|
559
602
|
}
|
|
560
603
|
|
|
561
|
-
internal.dispatchMessage(
|
|
604
|
+
internal.dispatchMessage(
|
|
605
|
+
{ type: "session_switch", sessionId: conversation.id },
|
|
606
|
+
socket,
|
|
607
|
+
);
|
|
562
608
|
await new Promise((r) => setTimeout(r, 50));
|
|
563
609
|
|
|
564
610
|
// The recreated session should have the private policy
|
|
565
611
|
expect(lastCreatedMemoryPolicy).toEqual({
|
|
566
|
-
scopeId:
|
|
612
|
+
scopeId: "private:conv-1",
|
|
567
613
|
includeDefaultFallback: true,
|
|
568
614
|
strictSideEffects: true,
|
|
569
615
|
});
|
|
570
616
|
});
|
|
571
617
|
|
|
572
|
-
test(
|
|
618
|
+
test("session_create normalizes unrecognized threadType to standard", async () => {
|
|
573
619
|
const server = new DaemonServer();
|
|
574
620
|
const internal = asDaemonServerTestAccess(server);
|
|
575
621
|
const { socket, writes } = createFakeSocket();
|
|
576
622
|
|
|
577
|
-
internal.dispatchMessage(
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
623
|
+
internal.dispatchMessage(
|
|
624
|
+
{
|
|
625
|
+
type: "session_create",
|
|
626
|
+
title: "Bad threadType",
|
|
627
|
+
threadType: "bogus" as unknown,
|
|
628
|
+
},
|
|
629
|
+
socket,
|
|
630
|
+
);
|
|
582
631
|
await new Promise((r) => setTimeout(r, 50));
|
|
583
632
|
|
|
584
633
|
// Should normalize to 'standard'
|
|
585
634
|
expect(lastCreateConversationArgs).toEqual({
|
|
586
|
-
title:
|
|
587
|
-
threadType:
|
|
635
|
+
title: "Bad threadType",
|
|
636
|
+
threadType: "standard",
|
|
588
637
|
});
|
|
589
638
|
|
|
590
639
|
const messages = decodeMessages(writes);
|
|
591
|
-
const sessionInfo = messages.find((msg) => msg.type ===
|
|
640
|
+
const sessionInfo = messages.find((msg) => msg.type === "session_info");
|
|
592
641
|
expect(sessionInfo).toBeDefined();
|
|
593
|
-
expect(sessionInfo!.threadType).toBe(
|
|
642
|
+
expect(sessionInfo!.threadType).toBe("standard");
|
|
594
643
|
});
|
|
595
644
|
|
|
596
|
-
test(
|
|
645
|
+
test("session_create defaults missing threadType to standard", async () => {
|
|
597
646
|
const server = new DaemonServer();
|
|
598
647
|
const internal = asDaemonServerTestAccess(server);
|
|
599
648
|
const { socket, writes } = createFakeSocket();
|
|
600
649
|
|
|
601
|
-
internal.dispatchMessage(
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
650
|
+
internal.dispatchMessage(
|
|
651
|
+
{
|
|
652
|
+
type: "session_create",
|
|
653
|
+
title: "No threadType",
|
|
654
|
+
},
|
|
655
|
+
socket,
|
|
656
|
+
);
|
|
605
657
|
await new Promise((r) => setTimeout(r, 50));
|
|
606
658
|
|
|
607
659
|
expect(lastCreateConversationArgs).toEqual({
|
|
608
|
-
title:
|
|
609
|
-
threadType:
|
|
660
|
+
title: "No threadType",
|
|
661
|
+
threadType: "standard",
|
|
610
662
|
});
|
|
611
663
|
|
|
612
664
|
const messages = decodeMessages(writes);
|
|
613
|
-
const sessionInfo = messages.find((msg) => msg.type ===
|
|
665
|
+
const sessionInfo = messages.find((msg) => msg.type === "session_info");
|
|
614
666
|
expect(sessionInfo).toBeDefined();
|
|
615
|
-
expect(sessionInfo!.threadType).toBe(
|
|
667
|
+
expect(sessionInfo!.threadType).toBe("standard");
|
|
616
668
|
});
|
|
617
669
|
|
|
618
|
-
test(
|
|
670
|
+
test("session_create with private threadType derives correct policy", async () => {
|
|
619
671
|
// conversation starts as 'standard' from beforeEach — the mock
|
|
620
672
|
// createConversation must derive private state from the IPC request.
|
|
621
673
|
const server = new DaemonServer();
|
|
622
674
|
const internal = asDaemonServerTestAccess(server);
|
|
623
675
|
const { socket } = createFakeSocket();
|
|
624
676
|
|
|
625
|
-
internal.dispatchMessage(
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
677
|
+
internal.dispatchMessage(
|
|
678
|
+
{
|
|
679
|
+
type: "session_create",
|
|
680
|
+
title: "Private Thread",
|
|
681
|
+
threadType: "private",
|
|
682
|
+
},
|
|
683
|
+
socket,
|
|
684
|
+
);
|
|
630
685
|
await new Promise((r) => setTimeout(r, 50));
|
|
631
686
|
|
|
632
687
|
// Verify createConversation received the threadType
|
|
633
688
|
expect(lastCreateConversationArgs).toEqual({
|
|
634
|
-
title:
|
|
635
|
-
threadType:
|
|
689
|
+
title: "Private Thread",
|
|
690
|
+
threadType: "private",
|
|
636
691
|
});
|
|
637
692
|
|
|
638
693
|
const session = internal.sessions.get(conversation.id);
|
|
639
694
|
expect(session).toBeDefined();
|
|
640
695
|
expect(session!.memoryPolicy).toEqual({
|
|
641
|
-
scopeId:
|
|
696
|
+
scopeId: "private:conv-1",
|
|
642
697
|
includeDefaultFallback: true,
|
|
643
698
|
strictSideEffects: true,
|
|
644
699
|
});
|
|
645
700
|
});
|
|
646
701
|
|
|
647
|
-
test(
|
|
702
|
+
test("interactive HTTP processing marks no-socket sessions interactive and registers confirmation prompts", async () => {
|
|
648
703
|
const server = new DaemonServer();
|
|
649
704
|
const internal = asDaemonServerTestAccess(server);
|
|
650
705
|
|
|
651
706
|
// Pre-configure the mock to emit a confirmation_request during runAgentLoop,
|
|
652
707
|
// simulating a tool requesting approval while the session is interactive.
|
|
653
708
|
mockConfirmationToEmitDuringLoop = {
|
|
654
|
-
type:
|
|
655
|
-
requestId:
|
|
656
|
-
toolName:
|
|
657
|
-
input: { title:
|
|
658
|
-
riskLevel:
|
|
659
|
-
allowlistOptions: [
|
|
660
|
-
|
|
709
|
+
type: "confirmation_request",
|
|
710
|
+
requestId: "req-interactive-1",
|
|
711
|
+
toolName: "notify_desktop",
|
|
712
|
+
input: { title: "Weather" },
|
|
713
|
+
riskLevel: "high",
|
|
714
|
+
allowlistOptions: [
|
|
715
|
+
{
|
|
716
|
+
label: "notify_desktop:*",
|
|
717
|
+
description: "notify_desktop:*",
|
|
718
|
+
pattern: "notify_desktop:*",
|
|
719
|
+
},
|
|
720
|
+
],
|
|
721
|
+
scopeOptions: [{ label: "everywhere", scope: "everywhere" }],
|
|
661
722
|
persistentDecisionsAllowed: true,
|
|
662
723
|
};
|
|
663
724
|
|
|
664
725
|
await server.processMessage(
|
|
665
726
|
conversation.id,
|
|
666
|
-
|
|
727
|
+
"send me a notification",
|
|
667
728
|
undefined,
|
|
668
729
|
{ isInteractive: true },
|
|
669
|
-
|
|
670
|
-
|
|
730
|
+
"telegram",
|
|
731
|
+
"telegram",
|
|
671
732
|
);
|
|
672
733
|
|
|
673
734
|
mockConfirmationToEmitDuringLoop = undefined;
|
|
@@ -689,61 +750,67 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
689
750
|
expect(session!.lastUpdateClientHasNoClient).toBe(true);
|
|
690
751
|
|
|
691
752
|
// The pending interaction was registered during the loop.
|
|
692
|
-
const interaction = pendingInteractions.get(
|
|
753
|
+
const interaction = pendingInteractions.get("req-interactive-1");
|
|
693
754
|
expect(interaction).toBeDefined();
|
|
694
|
-
expect(interaction?.kind).toBe(
|
|
755
|
+
expect(interaction?.kind).toBe("confirmation");
|
|
695
756
|
expect(interaction?.conversationId).toBe(conversation.id);
|
|
696
757
|
});
|
|
697
758
|
|
|
698
|
-
test(
|
|
759
|
+
test("confirmation_request canonical records include bound guardian identity context", async () => {
|
|
699
760
|
const server = new DaemonServer();
|
|
700
761
|
|
|
701
762
|
mockConfirmationToEmitDuringLoop = {
|
|
702
|
-
type:
|
|
703
|
-
requestId:
|
|
704
|
-
toolName:
|
|
705
|
-
input: { command:
|
|
706
|
-
riskLevel:
|
|
707
|
-
allowlistOptions: [
|
|
708
|
-
|
|
763
|
+
type: "confirmation_request",
|
|
764
|
+
requestId: "req-bound-1",
|
|
765
|
+
toolName: "host_bash",
|
|
766
|
+
input: { command: "ls" },
|
|
767
|
+
riskLevel: "high",
|
|
768
|
+
allowlistOptions: [
|
|
769
|
+
{
|
|
770
|
+
label: "host_bash:*",
|
|
771
|
+
description: "host_bash:*",
|
|
772
|
+
pattern: "host_bash:*",
|
|
773
|
+
},
|
|
774
|
+
],
|
|
775
|
+
scopeOptions: [{ label: "everywhere", scope: "everywhere" }],
|
|
709
776
|
persistentDecisionsAllowed: true,
|
|
710
777
|
};
|
|
711
778
|
|
|
712
779
|
await server.processMessage(
|
|
713
780
|
conversation.id,
|
|
714
|
-
|
|
781
|
+
"run ls",
|
|
715
782
|
undefined,
|
|
716
783
|
{
|
|
717
784
|
isInteractive: false,
|
|
718
785
|
guardianContext: {
|
|
719
|
-
sourceChannel:
|
|
720
|
-
trustClass:
|
|
721
|
-
guardianExternalUserId:
|
|
722
|
-
requesterExternalUserId:
|
|
723
|
-
requesterChatId:
|
|
786
|
+
sourceChannel: "telegram",
|
|
787
|
+
trustClass: "trusted_contact",
|
|
788
|
+
guardianExternalUserId: "guardian-123",
|
|
789
|
+
requesterExternalUserId: "trusted-456",
|
|
790
|
+
requesterChatId: "chat-789",
|
|
724
791
|
},
|
|
725
792
|
},
|
|
726
|
-
|
|
727
|
-
|
|
793
|
+
"telegram",
|
|
794
|
+
"telegram",
|
|
728
795
|
);
|
|
729
796
|
|
|
730
797
|
expect(lastCanonicalGuardianCreateParams).toBeDefined();
|
|
731
798
|
expect(lastCanonicalGuardianCreateParams).toMatchObject({
|
|
732
|
-
id:
|
|
733
|
-
kind:
|
|
734
|
-
sourceType:
|
|
735
|
-
sourceChannel:
|
|
799
|
+
id: "req-bound-1",
|
|
800
|
+
kind: "tool_approval",
|
|
801
|
+
sourceType: "channel",
|
|
802
|
+
sourceChannel: "telegram",
|
|
736
803
|
conversationId: conversation.id,
|
|
737
|
-
guardianExternalUserId:
|
|
738
|
-
requesterExternalUserId:
|
|
739
|
-
requesterChatId:
|
|
740
|
-
toolName:
|
|
741
|
-
status:
|
|
742
|
-
requestCode:
|
|
804
|
+
guardianExternalUserId: "guardian-123",
|
|
805
|
+
requesterExternalUserId: "trusted-456",
|
|
806
|
+
requesterChatId: "chat-789",
|
|
807
|
+
toolName: "host_bash",
|
|
808
|
+
status: "pending",
|
|
809
|
+
requestCode: "mock-code-0000",
|
|
743
810
|
});
|
|
744
811
|
});
|
|
745
812
|
|
|
746
|
-
test(
|
|
813
|
+
test("finally block does not overwrite IPC client that connected during interactive agent loop (processMessage)", async () => {
|
|
747
814
|
const server = new DaemonServer();
|
|
748
815
|
const internal = asDaemonServerTestAccess(server);
|
|
749
816
|
|
|
@@ -757,11 +824,11 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
757
824
|
|
|
758
825
|
await server.processMessage(
|
|
759
826
|
conversation.id,
|
|
760
|
-
|
|
827
|
+
"hello",
|
|
761
828
|
undefined,
|
|
762
829
|
{ isInteractive: true },
|
|
763
|
-
|
|
764
|
-
|
|
830
|
+
"telegram",
|
|
831
|
+
"telegram",
|
|
765
832
|
);
|
|
766
833
|
|
|
767
834
|
mockMidLoopCallback = undefined;
|
|
@@ -776,7 +843,7 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
776
843
|
expect(session!.lastUpdateClientHasNoClient).toBe(false);
|
|
777
844
|
});
|
|
778
845
|
|
|
779
|
-
test(
|
|
846
|
+
test("finally block does not overwrite IPC client that connected during interactive agent loop (persistAndProcessMessage)", async () => {
|
|
780
847
|
const server = new DaemonServer();
|
|
781
848
|
const internal = asDaemonServerTestAccess(server);
|
|
782
849
|
|
|
@@ -790,13 +857,13 @@ describe('DaemonServer initial session hydration', () => {
|
|
|
790
857
|
|
|
791
858
|
const { messageId } = await server.persistAndProcessMessage(
|
|
792
859
|
conversation.id,
|
|
793
|
-
|
|
860
|
+
"hello",
|
|
794
861
|
undefined,
|
|
795
862
|
{ isInteractive: true },
|
|
796
|
-
|
|
797
|
-
|
|
863
|
+
"telegram",
|
|
864
|
+
"telegram",
|
|
798
865
|
);
|
|
799
|
-
expect(messageId).toBe(
|
|
866
|
+
expect(messageId).toBe("msg-1");
|
|
800
867
|
|
|
801
868
|
// persistAndProcessMessage fires the loop in the background; wait for it.
|
|
802
869
|
await new Promise((r) => setTimeout(r, 50));
|