@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.
Files changed (34) hide show
  1. package/docs/architecture/scheduling.md +7 -5
  2. package/docs/guardian-request-flow.md +1 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/emit-signal-routing-intent.test.ts +62 -0
  5. package/src/__tests__/introduction-card-resolver.test.ts +53 -8
  6. package/src/__tests__/non-member-access-request.test.ts +48 -0
  7. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -6
  8. package/src/approvals/guardian-decision-primitive.ts +3 -2
  9. package/src/approvals/guardian-request-resolvers.ts +31 -24
  10. package/src/daemon/activity-trail.test.ts +206 -0
  11. package/src/daemon/activity-trail.ts +190 -0
  12. package/src/daemon/conversation-media-retry.ts +1 -2
  13. package/src/daemon/conversation.ts +11 -6
  14. package/src/daemon/event-loop-watchdog.test.ts +182 -2
  15. package/src/daemon/event-loop-watchdog.ts +168 -6
  16. package/src/monitoring/__tests__/proc-wait-state.test.ts +233 -0
  17. package/src/monitoring/proc-wait-state.ts +319 -0
  18. package/src/monitoring/resource-sampler.ts +4 -1
  19. package/src/monitoring/stall-capture.ts +12 -0
  20. package/src/notifications/README.md +6 -6
  21. package/src/notifications/emit-signal.ts +16 -1
  22. package/src/runtime/access-request-helper.ts +16 -9
  23. package/src/runtime/agent-wake.ts +1 -2
  24. package/src/runtime/http-server.ts +4 -1
  25. package/src/runtime/introduction-policy.test.ts +17 -1
  26. package/src/runtime/introduction-policy.ts +23 -6
  27. package/src/runtime/migrations/vbundle-builder.ts +9 -0
  28. package/src/runtime/routes/conversation-management-routes.ts +1 -3
  29. package/src/runtime/routes/migration-routes.ts +8 -2
  30. package/src/runtime/routes/user-route-dispatcher.ts +1 -2
  31. package/src/schedule/scheduler.ts +9 -0
  32. package/src/tools/browser/cdp-client/local-cdp-client.ts +1 -2
  33. package/src/tools/workspace-tools/loader.ts +1 -2
  34. 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` | No override. The LLM's channel selection stands. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.4-dev.202609232047.847daa9",
3
+ "version": "0.12.4-dev.202609232215.634e2d2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- expect(introductionOutcomeForAction("reject")).toBe("leave_unverified");
401
- expect(introductionOutcomeForAction("leave_unverified")).toBe(
402
- "leave_unverified",
403
- );
404
- expect(introductionOutcomeForAction("approve_once")).toBe("verify_code");
405
- expect(introductionOutcomeForAction("verify_code")).toBe("verify_code");
406
- expect(introductionOutcomeForAction("trust")).toBe("trust");
407
- expect(introductionOutcomeForAction("block")).toBe("block");
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. email) still records verification_sent", async () => {
1312
- // `email` has no deliver URL (resolveDeliverCallbackUrlForChannel returns
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: "email",
1318
- requesterChatId: "requester@example.com",
1319
- sourceConversationId: "conv-access-email",
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@example.com",
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`), so the resolved card must reflect the OUTCOME, not the raw
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 request.
623
- * The generic decision pair folds onto the card outcomes (`reject` →
624
- * `leave_unverified`, `approve_once` → `verify_code`); the introduction actions
625
- * map to themselves. Every outcome is itself an `ApprovalAction`, so a caller
626
- * that must reflect the resolved *outcome* rather than the raw button — the
627
- * resolved-card projection, so a `reject` that parked the contact at
628
- * `unverified` reads as the neutral "Left unverified" and not "Denied" — can
629
- * normalize through this. It does not apply the bot handshake→trust coercion,
630
- * which does not affect the park/deny distinction the card cares about.
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
- ): ApprovalAction {
635
- return OUTCOME_BY_ACTION[action];
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
- let outcome: IntroductionOutcome = OUTCOME_BY_ACTION[action];
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 bot requester can never return a code, so handshake approvals are
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: "resolver_access_request_bot_coercion",
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 on a bot coerced to direct trust",
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
+ });