@vellumai/assistant 0.12.4-dev.202609232047.847daa9 → 0.12.4-dev.202609232215.634e2d2
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/docs/architecture/scheduling.md +7 -5
- package/docs/guardian-request-flow.md +1 -1
- package/package.json +1 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +62 -0
- package/src/__tests__/introduction-card-resolver.test.ts +53 -8
- package/src/__tests__/non-member-access-request.test.ts +48 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -6
- package/src/approvals/guardian-decision-primitive.ts +3 -2
- package/src/approvals/guardian-request-resolvers.ts +31 -24
- package/src/daemon/activity-trail.test.ts +206 -0
- package/src/daemon/activity-trail.ts +190 -0
- package/src/daemon/conversation-media-retry.ts +1 -2
- package/src/daemon/conversation.ts +11 -6
- package/src/daemon/event-loop-watchdog.test.ts +182 -2
- package/src/daemon/event-loop-watchdog.ts +168 -6
- package/src/monitoring/__tests__/proc-wait-state.test.ts +233 -0
- package/src/monitoring/proc-wait-state.ts +319 -0
- package/src/monitoring/resource-sampler.ts +4 -1
- package/src/monitoring/stall-capture.ts +12 -0
- package/src/notifications/README.md +6 -6
- package/src/notifications/emit-signal.ts +16 -1
- package/src/runtime/access-request-helper.ts +16 -9
- package/src/runtime/agent-wake.ts +1 -2
- package/src/runtime/http-server.ts +4 -1
- package/src/runtime/introduction-policy.test.ts +17 -1
- package/src/runtime/introduction-policy.ts +23 -6
- package/src/runtime/migrations/vbundle-builder.ts +9 -0
- package/src/runtime/routes/conversation-management-routes.ts +1 -3
- package/src/runtime/routes/migration-routes.ts +8 -2
- package/src/runtime/routes/user-route-dispatcher.ts +1 -2
- package/src/schedule/scheduler.ts +9 -0
- package/src/tools/browser/cdp-client/local-cdp-client.ts +1 -2
- package/src/tools/workspace-tools/loader.ts +1 -2
- package/src/workspace/git-service.ts +6 -1
|
@@ -94,11 +94,13 @@ sequenceDiagram
|
|
|
94
94
|
|
|
95
95
|
The `enforceRoutingIntent()` step runs after the LLM produces a channel selection but before deterministic checks. It acts as a post-decision guard:
|
|
96
96
|
|
|
97
|
-
| Intent | Enforcement Rule
|
|
98
|
-
| ---------------- |
|
|
99
|
-
| `single_channel` |
|
|
100
|
-
| `multi_channel` | If the LLM selected < 2 channels and 2+ are connected, expand to at least two connected channels.
|
|
101
|
-
| `all_channels` | Replace the LLM's selection with all connected channels.
|
|
97
|
+
| Intent | Enforcement Rule |
|
|
98
|
+
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `single_channel` | Cap delivery to one channel: the signal's source channel when it is connected, otherwise the first channel the LLM selected. |
|
|
100
|
+
| `multi_channel` | If the LLM selected < 2 channels and 2+ are connected, expand to at least two connected channels. |
|
|
101
|
+
| `all_channels` | Replace the LLM's selection with all connected channels. |
|
|
102
|
+
|
|
103
|
+
A decision the LLM suppressed (`shouldNotify: false`) is left alone under every intent. A signal with no routing intent keeps the LLM's selection.
|
|
102
104
|
|
|
103
105
|
When enforcement changes the decision, the updated `selectedChannels` and annotated `reasoningSummary` are re-persisted to `notification_decisions` so the audit trail reflects what was actually dispatched.
|
|
104
106
|
|
|
@@ -24,7 +24,7 @@ anti-pattern was retired in #35642 and again in the ask_question redesign).
|
|
|
24
24
|
(generic actions[] + plainTextFallback) resolveQuestionContext)
|
|
25
25
|
│
|
|
26
26
|
▼ per-channel rendering ONLY
|
|
27
|
-
channel adapters (notifications/adapters/{telegram,slack,macos,platform},
|
|
27
|
+
channel adapters (notifications/adapters/{telegram,slack,discord,macos,platform},
|
|
28
28
|
telegram: inline keyboard; slack: blocks; vellum: conversation card via approval-card-builder)
|
|
29
29
|
card deliveries recorded per channel (guardian-delivery-recorder.ts → guardian_request_deliveries)
|
|
30
30
|
│
|
package/package.json
CHANGED
|
@@ -359,6 +359,68 @@ describe("access-request vellum floor", () => {
|
|
|
359
359
|
);
|
|
360
360
|
});
|
|
361
361
|
|
|
362
|
+
test("an all_channels access request the decision engine suppressed still fans out", async () => {
|
|
363
|
+
evaluateSignalMock.mockResolvedValue({
|
|
364
|
+
shouldNotify: false,
|
|
365
|
+
selectedChannels: [],
|
|
366
|
+
reasoningSummary: "LLM suppressed",
|
|
367
|
+
renderedCopy: {},
|
|
368
|
+
dedupeKey: "dedupe-ar-email",
|
|
369
|
+
confidence: 0.9,
|
|
370
|
+
fallbackUsed: false,
|
|
371
|
+
persistedDecisionId: "dec-ar-email",
|
|
372
|
+
});
|
|
373
|
+
// The real enforcement leaves a suppressed decision alone and replaces
|
|
374
|
+
// the selection with every connected channel otherwise.
|
|
375
|
+
enforceRoutingIntentMock.mockImplementation(
|
|
376
|
+
(
|
|
377
|
+
decision: { shouldNotify: boolean; selectedChannels: string[] },
|
|
378
|
+
intent: string | undefined,
|
|
379
|
+
connected: string[],
|
|
380
|
+
) =>
|
|
381
|
+
intent === "all_channels" && decision.shouldNotify
|
|
382
|
+
? { ...decision, selectedChannels: [...connected] }
|
|
383
|
+
: decision,
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
await emitNotificationSignal({
|
|
387
|
+
sourceEventName: "ingress.access_request",
|
|
388
|
+
sourceChannel: "email",
|
|
389
|
+
sourceContextId: "access-req-email-stranger",
|
|
390
|
+
requiresConversation: true,
|
|
391
|
+
routingIntent: "all_channels",
|
|
392
|
+
attentionHints: {
|
|
393
|
+
requiresAction: true,
|
|
394
|
+
urgency: "high",
|
|
395
|
+
isAsyncBackground: false,
|
|
396
|
+
visibleInSourceNow: false,
|
|
397
|
+
},
|
|
398
|
+
contextPayload: {
|
|
399
|
+
requestId: "req-email",
|
|
400
|
+
requestCode: "EM12CD",
|
|
401
|
+
sourceChannel: "email",
|
|
402
|
+
conversationExternalId: "<thread-1@example.com>",
|
|
403
|
+
actorExternalId: "stranger@example.com",
|
|
404
|
+
actorDisplayName: null,
|
|
405
|
+
actorUsername: null,
|
|
406
|
+
senderIdentifier: "stranger@example.com",
|
|
407
|
+
guardianBindingChannel: null,
|
|
408
|
+
guardianResolutionSource: "vellum-anchor",
|
|
409
|
+
previousMemberStatus: null,
|
|
410
|
+
messagePreview: null,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const enforced = enforceRoutingIntentMock.mock.calls[0][0] as {
|
|
415
|
+
shouldNotify: boolean;
|
|
416
|
+
};
|
|
417
|
+
expect(enforced.shouldNotify).toBe(true);
|
|
418
|
+
const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
|
|
419
|
+
selectedChannels: string[];
|
|
420
|
+
};
|
|
421
|
+
expect(dispatched.selectedChannels).toContain("vellum");
|
|
422
|
+
});
|
|
423
|
+
|
|
362
424
|
test("re-adds vellum when single_channel routing enforcement strips it", async () => {
|
|
363
425
|
evaluateSignalMock.mockResolvedValue({
|
|
364
426
|
shouldNotify: true,
|
|
@@ -218,6 +218,29 @@ describe("introduction card decisions", () => {
|
|
|
218
218
|
}
|
|
219
219
|
});
|
|
220
220
|
|
|
221
|
+
test("handshake approval on an email sender is coerced to direct trust", async () => {
|
|
222
|
+
for (const action of ["approve_once", "verify_code"] as const) {
|
|
223
|
+
resetState();
|
|
224
|
+
const req = makeAccessRequest({ sourceChannel: "email" });
|
|
225
|
+
|
|
226
|
+
const result = await applyGuardianDecision({
|
|
227
|
+
requestId: req.id,
|
|
228
|
+
action,
|
|
229
|
+
actorContext: desktopGuardian(),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(result.applied).toBe(true);
|
|
233
|
+
// No code is minted: email has no route to deliver one to the sender.
|
|
234
|
+
expect(outcomesOfType("mint_outbound_session")).toHaveLength(0);
|
|
235
|
+
const activations = outcomesOfType("activate_member");
|
|
236
|
+
expect(activations).toHaveLength(1);
|
|
237
|
+
expect(activations[0].sourceChannel).toBe("email");
|
|
238
|
+
expect(activations[0].verifiedVia).toBe("manual_channel_claim");
|
|
239
|
+
// The reported and projected outcome is the trust that was applied.
|
|
240
|
+
expect(result.applied && result.decidedAction).toBe("trust");
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
221
244
|
test("leave_unverified persists the sender as unverified and is silent (legacy reject path)", async () => {
|
|
222
245
|
for (const action of ["leave_unverified", "reject"] as const) {
|
|
223
246
|
resetState();
|
|
@@ -392,18 +415,40 @@ describe("introduction card decisions", () => {
|
|
|
392
415
|
});
|
|
393
416
|
|
|
394
417
|
describe("introductionOutcomeForAction", () => {
|
|
418
|
+
const slackStranger = {
|
|
419
|
+
sourceChannel: "slack",
|
|
420
|
+
requesterSignals: serializeRequesterSignals({ isStranger: true }) ?? null,
|
|
421
|
+
};
|
|
422
|
+
|
|
395
423
|
test("folds the generic decision pair onto the introduction outcomes", () => {
|
|
396
424
|
// `reject` and `leave_unverified` both park the contact at `unverified`;
|
|
397
425
|
// `approve_once` starts the handshake. Introduction actions map to
|
|
398
426
|
// themselves. Callers that must present the resolved outcome (the card
|
|
399
427
|
// projection) rely on this so a `reject` park never reads as "Denied".
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
);
|
|
404
|
-
expect(
|
|
405
|
-
expect(
|
|
406
|
-
expect(
|
|
407
|
-
expect(
|
|
428
|
+
const outcome = (
|
|
429
|
+
action: Parameters<typeof introductionOutcomeForAction>[1],
|
|
430
|
+
) => introductionOutcomeForAction(slackStranger, action);
|
|
431
|
+
expect(outcome("reject")).toBe("leave_unverified");
|
|
432
|
+
expect(outcome("leave_unverified")).toBe("leave_unverified");
|
|
433
|
+
expect(outcome("approve_once")).toBe("verify_code");
|
|
434
|
+
expect(outcome("verify_code")).toBe("verify_code");
|
|
435
|
+
expect(outcome("trust")).toBe("trust");
|
|
436
|
+
expect(outcome("block")).toBe("block");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("a handshake approval resolves to trust where the handshake cannot complete", () => {
|
|
440
|
+
const email = { sourceChannel: "email", requesterSignals: null };
|
|
441
|
+
const bot = {
|
|
442
|
+
sourceChannel: "slack",
|
|
443
|
+
requesterSignals: serializeRequesterSignals({ isBot: true }) ?? null,
|
|
444
|
+
};
|
|
445
|
+
for (const request of [email, bot]) {
|
|
446
|
+
expect(introductionOutcomeForAction(request, "approve_once")).toBe(
|
|
447
|
+
"trust",
|
|
448
|
+
);
|
|
449
|
+
expect(introductionOutcomeForAction(request, "verify_code")).toBe(
|
|
450
|
+
"trust",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
408
453
|
});
|
|
409
454
|
});
|
|
@@ -598,6 +598,54 @@ describe("access-request-helper unit tests", () => {
|
|
|
598
598
|
expect(payload.guardianBindingChannel).toBe("vellum");
|
|
599
599
|
});
|
|
600
600
|
|
|
601
|
+
test("an email access request routes to every channel the guardian is connected on", async () => {
|
|
602
|
+
const result = await notifyGuardianOfAccessRequest({
|
|
603
|
+
sourceChannel: "email",
|
|
604
|
+
conversationExternalId: "<thread-1@example.com>",
|
|
605
|
+
actorExternalId: "stranger@example.com",
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
expect(result.notified).toBe(true);
|
|
609
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
610
|
+
expect(emitSignalCalls[0].routingIntent).toBe("all_channels");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("an admitted email sender's nudge keeps the default routing", async () => {
|
|
614
|
+
await maybeNotifyGuardianOfAdmittedContact({
|
|
615
|
+
sourceChannel: "email",
|
|
616
|
+
conversationExternalId: "<thread-2@example.com>",
|
|
617
|
+
actorExternalId: "admitted@example.com",
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
621
|
+
expect(emitSignalCalls[0].routingIntent).toBeUndefined();
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("a telegram access request with a telegram guardian stays on telegram", async () => {
|
|
625
|
+
seedGatewayGuardian({
|
|
626
|
+
channelType: "telegram",
|
|
627
|
+
address: "guardian-tg",
|
|
628
|
+
externalChatId: "tg-chat",
|
|
629
|
+
principalId: anchorPrincipalId,
|
|
630
|
+
});
|
|
631
|
+
createGuardianBinding({
|
|
632
|
+
channel: "telegram",
|
|
633
|
+
guardianExternalUserId: "guardian-tg",
|
|
634
|
+
guardianDeliveryChatId: "tg-chat",
|
|
635
|
+
guardianPrincipalId: anchorPrincipalId,
|
|
636
|
+
verifiedVia: "test",
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
await notifyGuardianOfAccessRequest({
|
|
640
|
+
sourceChannel: "telegram",
|
|
641
|
+
conversationExternalId: "chat-123",
|
|
642
|
+
actorExternalId: "unknown-user",
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
646
|
+
expect(emitSignalCalls[0].routingIntent).toBe("single_channel");
|
|
647
|
+
});
|
|
648
|
+
|
|
601
649
|
test("notifyGuardianOfAccessRequest prefers source-channel binding over vellum anchor", async () => {
|
|
602
650
|
// Both Telegram and voice bindings exist with the anchor principal
|
|
603
651
|
seedGatewayGuardian({
|
|
@@ -1308,15 +1308,15 @@ describe("(g) access_request resolver: requester code delivery", () => {
|
|
|
1308
1308
|
expect(verificationSent.length).toBe(1);
|
|
1309
1309
|
});
|
|
1310
1310
|
|
|
1311
|
-
test("off-channel approval on a channel with no deliverable callback (e.g.
|
|
1312
|
-
// `
|
|
1311
|
+
test("off-channel approval on a channel with no deliverable callback (e.g. a plugin channel) still records verification_sent", async () => {
|
|
1312
|
+
// `plugin` has no deliver URL (resolveDeliverCallbackUrlForChannel returns
|
|
1313
1313
|
// null), so the requester cannot be auto-notified here. The guardian still
|
|
1314
1314
|
// receives the code inline, so the lifecycle transition must be recorded —
|
|
1315
1315
|
// the emit must not be gated on requester deliverability.
|
|
1316
1316
|
const req = createAccessRequest({
|
|
1317
|
-
sourceChannel: "
|
|
1318
|
-
requesterChatId: "requester
|
|
1319
|
-
sourceConversationId: "conv-access-
|
|
1317
|
+
sourceChannel: "plugin",
|
|
1318
|
+
requesterChatId: "plugin-requester-1",
|
|
1319
|
+
sourceConversationId: "conv-access-plugin",
|
|
1320
1320
|
});
|
|
1321
1321
|
|
|
1322
1322
|
const result = await applyGuardianDecision({
|
|
@@ -1335,7 +1335,7 @@ describe("(g) access_request resolver: requester code delivery", () => {
|
|
|
1335
1335
|
const replyText = result.applied ? result.resolverReplyText : undefined;
|
|
1336
1336
|
expect(replyText).toContain("123456");
|
|
1337
1337
|
const requesterDelivery = deliveredReplies.find(
|
|
1338
|
-
(r) => r.payload.chatId === "requester
|
|
1338
|
+
(r) => r.payload.chatId === "plugin-requester-1",
|
|
1339
1339
|
);
|
|
1340
1340
|
expect(requesterDelivery).toBeUndefined();
|
|
1341
1341
|
|
|
@@ -529,12 +529,13 @@ export async function applyGuardianDecision(
|
|
|
529
529
|
// never throws; the `.catch` is a defensive backstop.
|
|
530
530
|
// For access requests the resolver folds the generic decision pair onto the
|
|
531
531
|
// introduction outcomes (`reject` → `leave_unverified`, `approve_once` →
|
|
532
|
-
// `verify_code
|
|
532
|
+
// `verify_code`, or `trust` for a requester who cannot complete a code
|
|
533
|
+
// handshake), so the resolved card must reflect the OUTCOME, not the raw
|
|
533
534
|
// button — otherwise a `reject` that actually parked the contact at
|
|
534
535
|
// `unverified` would still render "Denied". Other kinds have no such mapping.
|
|
535
536
|
const cardAction =
|
|
536
537
|
request.kind === "access_request"
|
|
537
|
-
? introductionOutcomeForAction(effectiveAction)
|
|
538
|
+
? introductionOutcomeForAction(resolved, effectiveAction)
|
|
538
539
|
: effectiveAction;
|
|
539
540
|
void withdrawGuardianRequestCards({
|
|
540
541
|
request: resolved,
|
|
@@ -46,6 +46,7 @@ import { deliverChannelReply } from "../runtime/gateway-client.js";
|
|
|
46
46
|
import {
|
|
47
47
|
introductionMode,
|
|
48
48
|
parseRequesterSignals,
|
|
49
|
+
requesterCanCompleteHandshake,
|
|
49
50
|
type RequesterIdentitySignals,
|
|
50
51
|
resolveTrustBinding,
|
|
51
52
|
} from "../runtime/introduction-policy.js";
|
|
@@ -619,20 +620,33 @@ const OUTCOME_BY_ACTION = {
|
|
|
619
620
|
} as const satisfies Record<ApprovalAction, IntroductionOutcome>;
|
|
620
621
|
|
|
621
622
|
/**
|
|
622
|
-
* The introduction outcome a decision action resolves to for an access
|
|
623
|
-
* The generic decision pair folds onto the card outcomes (`reject` →
|
|
624
|
-
* `leave_unverified`, `approve_once` → `verify_code`)
|
|
625
|
-
* map to themselves
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
623
|
+
* The introduction outcome a decision action resolves to for an access
|
|
624
|
+
* request. The generic decision pair folds onto the card outcomes (`reject` →
|
|
625
|
+
* `leave_unverified`, `approve_once` → `verify_code`) and the introduction
|
|
626
|
+
* actions map to themselves, except that a handshake approval on a requester
|
|
627
|
+
* who can never complete the handshake (a bot, an email sender) resolves to
|
|
628
|
+
* direct trust: the guardian's intent ("let them in") is unambiguous.
|
|
629
|
+
*
|
|
630
|
+
* The one derivation of the outcome: the resolver plans and follows through on
|
|
631
|
+
* it, and the decision primitive reports it (`decidedAction`) and projects it
|
|
632
|
+
* onto the resolved cards, so a `reject` that parked the contact reads as the
|
|
633
|
+
* neutral "Left unverified" and a coerced approval reads as the trust it was.
|
|
631
634
|
*/
|
|
632
635
|
export function introductionOutcomeForAction(
|
|
636
|
+
request: Pick<GuardianRequestWire, "sourceChannel" | "requesterSignals">,
|
|
633
637
|
action: ApprovalAction,
|
|
634
|
-
):
|
|
635
|
-
|
|
638
|
+
): IntroductionOutcome {
|
|
639
|
+
const outcome = OUTCOME_BY_ACTION[action];
|
|
640
|
+
if (
|
|
641
|
+
outcome === "verify_code" &&
|
|
642
|
+
!requesterCanCompleteHandshake(
|
|
643
|
+
request.sourceChannel ?? undefined,
|
|
644
|
+
parseRequesterSignals(request.requesterSignals),
|
|
645
|
+
)
|
|
646
|
+
) {
|
|
647
|
+
return "trust";
|
|
648
|
+
}
|
|
649
|
+
return outcome;
|
|
636
650
|
}
|
|
637
651
|
|
|
638
652
|
/** Derived access-request decision facts shared by `prepare` and `resolve`. */
|
|
@@ -675,15 +689,7 @@ function deriveAccessRequestDecision(
|
|
|
675
689
|
requesterContactResult?.contact.displayName ?? null;
|
|
676
690
|
|
|
677
691
|
const signals = parseRequesterSignals(request.requesterSignals);
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
// A bot cannot return a verification code, so a handshake approval on a
|
|
681
|
-
// bot requester can never complete. Coerce it to direct trust — the
|
|
682
|
-
// guardian's intent ("let it in") is unambiguous. Logged once, in
|
|
683
|
-
// `prepare` (this derivation runs again in `resolve`).
|
|
684
|
-
if (outcome === "verify_code" && signals.isBot === true) {
|
|
685
|
-
outcome = "trust";
|
|
686
|
-
}
|
|
692
|
+
const outcome = introductionOutcomeForAction(request, action);
|
|
687
693
|
|
|
688
694
|
return {
|
|
689
695
|
channel,
|
|
@@ -885,8 +891,8 @@ async function notifyRequesterOfDenial(params: {
|
|
|
885
891
|
* follow-through (requester/guardian notices, verification-code delivery
|
|
886
892
|
* from the decide's `mintedSession`, lifecycle signals).
|
|
887
893
|
*
|
|
888
|
-
* A
|
|
889
|
-
* coerced to direct trust.
|
|
894
|
+
* A requester who can never complete the handshake (a bot, an email
|
|
895
|
+
* sender) has handshake approvals coerced to direct trust.
|
|
890
896
|
*/
|
|
891
897
|
const accessRequestResolver: GuardianRequestResolver = {
|
|
892
898
|
kind: "access_request",
|
|
@@ -905,11 +911,12 @@ const accessRequestResolver: GuardianRequestResolver = {
|
|
|
905
911
|
if (outcome !== OUTCOME_BY_ACTION[decision.action]) {
|
|
906
912
|
log.info(
|
|
907
913
|
{
|
|
908
|
-
event: "
|
|
914
|
+
event: "resolver_access_request_trust_coercion",
|
|
909
915
|
requestId: request.id,
|
|
910
916
|
action: decision.action,
|
|
917
|
+
channel,
|
|
911
918
|
},
|
|
912
|
-
"Access request resolver: handshake approval
|
|
919
|
+
"Access request resolver: handshake approval coerced to direct trust (requester cannot complete a handshake)",
|
|
913
920
|
);
|
|
914
921
|
}
|
|
915
922
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the daemon activity trail. A block report asks "what overlapped
|
|
3
|
+
* the last N ms", so the trail must include work still running, include work
|
|
4
|
+
* that ended inside the window, exclude work that ended before it, and group
|
|
5
|
+
* identical labels so the report stays small.
|
|
6
|
+
*/
|
|
7
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
import type { InterfaceId } from "../channels/types.js";
|
|
10
|
+
import {
|
|
11
|
+
getActivityOverlapping,
|
|
12
|
+
resetActivityTrailForTests,
|
|
13
|
+
trackDaemonActivity,
|
|
14
|
+
turnActivityLabel,
|
|
15
|
+
turnConversationType,
|
|
16
|
+
} from "./activity-trail.js";
|
|
17
|
+
|
|
18
|
+
/** Resolves after `ms` of wall time. */
|
|
19
|
+
function sleep(ms: number): Promise<void> {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
resetActivityTrailForTests();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("getActivityOverlapping", () => {
|
|
28
|
+
test("reports running work and work that ended inside the window", async () => {
|
|
29
|
+
// GIVEN a background turn that has ended and a user turn still running
|
|
30
|
+
await trackDaemonActivity({ kind: "schedule_tick" }, async () => {});
|
|
31
|
+
let release!: () => void;
|
|
32
|
+
const userTurn = trackDaemonActivity(
|
|
33
|
+
{
|
|
34
|
+
kind: "turn",
|
|
35
|
+
conversationType: "standard",
|
|
36
|
+
callSite: "mainAgent",
|
|
37
|
+
turnInterface: "web",
|
|
38
|
+
interactive: true,
|
|
39
|
+
},
|
|
40
|
+
() => new Promise<void>((resolve) => (release = resolve)),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// WHEN the last minute is queried
|
|
44
|
+
const groups = getActivityOverlapping(60_000);
|
|
45
|
+
|
|
46
|
+
// THEN both appear, the user turn marked as running
|
|
47
|
+
expect(groups).toHaveLength(2);
|
|
48
|
+
expect(groups).toContainEqual(
|
|
49
|
+
expect.objectContaining({ kind: "schedule_tick", count: 1, running: 0 }),
|
|
50
|
+
);
|
|
51
|
+
expect(groups).toContainEqual(
|
|
52
|
+
expect.objectContaining({
|
|
53
|
+
kind: "turn",
|
|
54
|
+
turnInterface: "web",
|
|
55
|
+
interactive: true,
|
|
56
|
+
running: 1,
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
release();
|
|
60
|
+
await userTurn;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("excludes work that ended before the window began", async () => {
|
|
64
|
+
// GIVEN work that ended ~50ms ago
|
|
65
|
+
await trackDaemonActivity({ kind: "git_compaction" }, async () => {});
|
|
66
|
+
await sleep(50);
|
|
67
|
+
|
|
68
|
+
// WHEN only the last 10ms are queried
|
|
69
|
+
const groups = getActivityOverlapping(10);
|
|
70
|
+
|
|
71
|
+
// THEN it is not attributed to the window
|
|
72
|
+
expect(groups).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("groups identical labels and keeps the longest duration", async () => {
|
|
76
|
+
// GIVEN three heartbeat turns, one noticeably longer
|
|
77
|
+
const heartbeat = {
|
|
78
|
+
kind: "turn" as const,
|
|
79
|
+
conversationType: "background" as const,
|
|
80
|
+
callSite: "heartbeatAgent" as const,
|
|
81
|
+
interactive: false,
|
|
82
|
+
};
|
|
83
|
+
await trackDaemonActivity(heartbeat, async () => {});
|
|
84
|
+
await trackDaemonActivity(heartbeat, () => sleep(30));
|
|
85
|
+
await trackDaemonActivity(heartbeat, async () => {});
|
|
86
|
+
|
|
87
|
+
// WHEN the window covers all three
|
|
88
|
+
const groups = getActivityOverlapping(60_000);
|
|
89
|
+
|
|
90
|
+
// THEN they collapse into one group
|
|
91
|
+
expect(groups).toHaveLength(1);
|
|
92
|
+
expect(groups[0]).toMatchObject({ ...heartbeat, count: 3, running: 0 });
|
|
93
|
+
expect(groups[0]!.longestMs).toBeGreaterThanOrEqual(25);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a failing job still closes its mark", async () => {
|
|
97
|
+
// GIVEN tracked work that throws
|
|
98
|
+
await expect(
|
|
99
|
+
trackDaemonActivity({ kind: "vbundle_export" }, async () => {
|
|
100
|
+
throw new Error("boom");
|
|
101
|
+
}),
|
|
102
|
+
).rejects.toThrow("boom");
|
|
103
|
+
|
|
104
|
+
// WHEN the trail is queried
|
|
105
|
+
const groups = getActivityOverlapping(60_000);
|
|
106
|
+
|
|
107
|
+
// THEN the work is recorded as ended, not left running forever
|
|
108
|
+
expect(groups).toEqual([
|
|
109
|
+
expect.objectContaining({ kind: "vbundle_export", running: 0 }),
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("burst retention", () => {
|
|
115
|
+
test("a burst of finished work does not evict what overlapped a block", async () => {
|
|
116
|
+
// GIVEN a user turn that ends, then a burst of 500 short jobs
|
|
117
|
+
await trackDaemonActivity(
|
|
118
|
+
{ kind: "turn", conversationType: "standard", callSite: "mainAgent" },
|
|
119
|
+
async () => {},
|
|
120
|
+
);
|
|
121
|
+
for (let i = 0; i < 500; i++) {
|
|
122
|
+
await trackDaemonActivity({ kind: "schedule_tick" }, async () => {});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// WHEN the last minute is queried
|
|
126
|
+
const groups = getActivityOverlapping(60_000);
|
|
127
|
+
|
|
128
|
+
// THEN the earlier turn is still attributed
|
|
129
|
+
expect(groups).toContainEqual(
|
|
130
|
+
expect.objectContaining({ kind: "turn", count: 1 }),
|
|
131
|
+
);
|
|
132
|
+
expect(groups).toContainEqual(
|
|
133
|
+
expect.objectContaining({ kind: "schedule_tick", count: 500 }),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("turnActivityLabel", () => {
|
|
139
|
+
function conversation(opts: {
|
|
140
|
+
isSubagent?: boolean;
|
|
141
|
+
originInterface?: InterfaceId;
|
|
142
|
+
liveInterface?: InterfaceId;
|
|
143
|
+
}) {
|
|
144
|
+
return {
|
|
145
|
+
conversationType: "standard",
|
|
146
|
+
isSubagent: opts.isSubagent ?? false,
|
|
147
|
+
originInterface: opts.originInterface,
|
|
148
|
+
getTurnInterfaceContext: () =>
|
|
149
|
+
opts.liveInterface
|
|
150
|
+
? { userMessageInterface: opts.liveInterface }
|
|
151
|
+
: null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test("a subagent turn without an explicit call site is a subagent turn", () => {
|
|
156
|
+
// GIVEN a subagent conversation and a queue-drained turn with no call site
|
|
157
|
+
// WHEN its label is built
|
|
158
|
+
const label = turnActivityLabel(
|
|
159
|
+
conversation({ isSubagent: true }),
|
|
160
|
+
undefined,
|
|
161
|
+
false,
|
|
162
|
+
);
|
|
163
|
+
// THEN it matches the call site the loop resolves, not mainAgent
|
|
164
|
+
expect(label.callSite).toBe("subagentSpawn");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("an explicit call site wins", () => {
|
|
168
|
+
const label = turnActivityLabel(conversation({}), "heartbeatAgent", false);
|
|
169
|
+
expect(label.callSite).toBe("heartbeatAgent");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("the turn's own interface wins over the conversation's origin", () => {
|
|
173
|
+
// GIVEN a conversation started on web, now continued from Slack
|
|
174
|
+
// WHEN the Slack turn's label is built
|
|
175
|
+
const label = turnActivityLabel(
|
|
176
|
+
conversation({ originInterface: "web", liveInterface: "slack" }),
|
|
177
|
+
undefined,
|
|
178
|
+
true,
|
|
179
|
+
);
|
|
180
|
+
// THEN it is attributed to Slack
|
|
181
|
+
expect(label.turnInterface).toBe("slack");
|
|
182
|
+
expect(label.interactive).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("falls back to the origin interface, and omits it when unknown", () => {
|
|
186
|
+
expect(
|
|
187
|
+
turnActivityLabel(
|
|
188
|
+
conversation({ originInterface: "macos" }),
|
|
189
|
+
undefined,
|
|
190
|
+
false,
|
|
191
|
+
).turnInterface,
|
|
192
|
+
).toBe("macos");
|
|
193
|
+
expect(
|
|
194
|
+
turnActivityLabel(conversation({}), undefined, false),
|
|
195
|
+
).not.toHaveProperty("turnInterface");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("turnConversationType", () => {
|
|
200
|
+
test("keeps known types, defaults a missing one, and buckets the rest", () => {
|
|
201
|
+
expect(turnConversationType("background")).toBe("background");
|
|
202
|
+
expect(turnConversationType("scheduled")).toBe("scheduled");
|
|
203
|
+
expect(turnConversationType(undefined)).toBe("standard");
|
|
204
|
+
expect(turnConversationType("something-new")).toBe("other");
|
|
205
|
+
});
|
|
206
|
+
});
|