@vellumai/assistant 0.12.4-dev.202609232115.73e8b74 → 0.12.4-dev.202609232312.d1432a4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.4-dev.202609232115.73e8b74",
3
+ "version": "0.12.4-dev.202609232312.d1432a4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Recent work this process ran, so an event-loop block report can say what
3
+ * was running while the loop was blocked.
4
+ *
5
+ * The section trail (`persistence/slow-sync-log.ts`) names instrumented
6
+ * synchronous sections, which cover a small share of blocks. This trail
7
+ * records coarse units of work instead: every agent turn, labelled by
8
+ * conversation type, call site, and interface, plus the non-turn background
9
+ * jobs that run on the daemon (the scheduler tick, bundle export and import,
10
+ * the channel retry sweep, git history compaction). A block report groups
11
+ * the entries that overlap its window, which
12
+ * answers whether background work shared the loop with a user-facing turn.
13
+ *
14
+ * Labels are closed sets (no free-form strings), so the grouped result is
15
+ * safe to send as telemetry. The trail is per process; only the daemon
16
+ * reports it.
17
+ */
18
+
19
+ import type { InterfaceId } from "../channels/types.js";
20
+ import type { LLMCallSite } from "../config/schemas/llm.js";
21
+ import type { Conversation } from "./conversation.js";
22
+ import { resolveTurnCallSite } from "./turn-call-site.js";
23
+
24
+ export type DaemonActivityKind =
25
+ | "turn"
26
+ | "schedule_tick"
27
+ | "vbundle_export"
28
+ | "vbundle_import"
29
+ | "channel_retry_sweep"
30
+ | "git_compaction";
31
+
32
+ /** Conversation types a turn label carries; anything else is "other". */
33
+ const TURN_CONVERSATION_TYPES = new Set([
34
+ "standard",
35
+ "background",
36
+ "scheduled",
37
+ ]);
38
+
39
+ export interface DaemonActivityLabel {
40
+ kind: DaemonActivityKind;
41
+ /** Turns only: the conversation's type. */
42
+ conversationType?: "standard" | "background" | "scheduled" | "other";
43
+ /** Turns only: the LLM call site the turn runs under. */
44
+ callSite?: LLMCallSite;
45
+ /**
46
+ * Turns only: the interface of the message that started the turn, else
47
+ * the interface the conversation came from.
48
+ */
49
+ turnInterface?: InterfaceId;
50
+ /** Turns only: whether a client is attached and waiting on the reply. */
51
+ interactive?: boolean;
52
+ }
53
+
54
+ export interface DaemonActivityGroup extends DaemonActivityLabel {
55
+ /** Entries with this label that overlapped the window. */
56
+ count: number;
57
+ /** Of those, how many were still running at report time. */
58
+ running: number;
59
+ /** Longest single entry, clamped to the report time for running ones. */
60
+ longestMs: number;
61
+ }
62
+
63
+ interface ActivityMark {
64
+ label: DaemonActivityLabel;
65
+ startedAt: number;
66
+ endedAt?: number;
67
+ }
68
+
69
+ /**
70
+ * Ended entries are kept for this long, so a block of up to this length can
71
+ * still see the work that overlapped it, however much ended meanwhile.
72
+ */
73
+ const ENDED_MARK_RETENTION_MS = 30 * 60_000;
74
+ /** Hard cap on ended entries, bounding memory under a pathological burst. */
75
+ const MAX_ENDED_MARKS = 2_000;
76
+ /** Cap on groups in one report, keeping it inside the telemetry byte budget. */
77
+ const MAX_GROUPS = 10;
78
+
79
+ const runningMarks = new Set<ActivityMark>();
80
+ const endedMarks: ActivityMark[] = [];
81
+
82
+ /** Narrow a conversation's stored type to the closed label set. */
83
+ export function turnConversationType(
84
+ value: string | undefined,
85
+ ): DaemonActivityLabel["conversationType"] {
86
+ if (value === undefined) {
87
+ return "standard";
88
+ }
89
+ return TURN_CONVERSATION_TYPES.has(value)
90
+ ? (value as "standard" | "background" | "scheduled")
91
+ : "other";
92
+ }
93
+
94
+ /**
95
+ * The label for a turn about to run on `conversation`. Uses the same
96
+ * call-site default and interface precedence as the agent loop: the
97
+ * interface of the message that started the turn, else the conversation's
98
+ * origin interface.
99
+ */
100
+ export function turnActivityLabel(
101
+ conversation: Pick<
102
+ Conversation,
103
+ "conversationType" | "isSubagent" | "originInterface"
104
+ > & {
105
+ getTurnInterfaceContext(): { userMessageInterface?: InterfaceId } | null;
106
+ },
107
+ explicitCallSite: LLMCallSite | undefined,
108
+ isInteractive: boolean | undefined,
109
+ ): DaemonActivityLabel {
110
+ const turnInterface =
111
+ conversation.getTurnInterfaceContext()?.userMessageInterface ??
112
+ conversation.originInterface;
113
+ return {
114
+ kind: "turn",
115
+ conversationType: turnConversationType(conversation.conversationType),
116
+ callSite: resolveTurnCallSite(explicitCallSite, conversation),
117
+ ...(turnInterface ? { turnInterface } : {}),
118
+ interactive: isInteractive ?? false,
119
+ };
120
+ }
121
+
122
+ /** Run `fn` as a unit of tracked work. The mark closes however `fn` settles. */
123
+ export async function trackDaemonActivity<T>(
124
+ label: DaemonActivityLabel,
125
+ fn: () => Promise<T>,
126
+ ): Promise<T> {
127
+ const mark: ActivityMark = { label, startedAt: performance.now() };
128
+ runningMarks.add(mark);
129
+ try {
130
+ return await fn();
131
+ } finally {
132
+ mark.endedAt = performance.now();
133
+ runningMarks.delete(mark);
134
+ endedMarks.push(mark);
135
+ // Marks end in order, so the oldest are at the front.
136
+ const cutoff = mark.endedAt - ENDED_MARK_RETENTION_MS;
137
+ let expired = 0;
138
+ while (
139
+ expired < endedMarks.length &&
140
+ (endedMarks[expired]!.endedAt! < cutoff ||
141
+ endedMarks.length - expired > MAX_ENDED_MARKS)
142
+ ) {
143
+ expired++;
144
+ }
145
+ endedMarks.splice(0, expired);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Work that overlapped the last `windowMs`, grouped by label, longest first.
151
+ * A mark overlaps when it started before `now` and had not ended before the
152
+ * window began.
153
+ */
154
+ export function getActivityOverlapping(
155
+ windowMs: number,
156
+ now: number = performance.now(),
157
+ ): DaemonActivityGroup[] {
158
+ const windowStart = now - windowMs;
159
+ const groups = new Map<string, DaemonActivityGroup>();
160
+ for (const mark of [...runningMarks, ...endedMarks]) {
161
+ const end = mark.endedAt ?? now;
162
+ if (mark.startedAt > now || end < windowStart) {
163
+ continue;
164
+ }
165
+ const key = JSON.stringify(mark.label);
166
+ const durationMs = Math.round(end - mark.startedAt);
167
+ const group = groups.get(key);
168
+ if (group) {
169
+ group.count++;
170
+ group.running += mark.endedAt === undefined ? 1 : 0;
171
+ group.longestMs = Math.max(group.longestMs, durationMs);
172
+ } else {
173
+ groups.set(key, {
174
+ ...mark.label,
175
+ count: 1,
176
+ running: mark.endedAt === undefined ? 1 : 0,
177
+ longestMs: durationMs,
178
+ });
179
+ }
180
+ }
181
+ return [...groups.values()]
182
+ .sort((a, b) => b.longestMs - a.longestMs)
183
+ .slice(0, MAX_GROUPS);
184
+ }
185
+
186
+ /** Test isolation only: the trail is process-global module state. */
187
+ export function resetActivityTrailForTests(): void {
188
+ runningMarks.clear();
189
+ endedMarks.length = 0;
190
+ }
@@ -294,7 +294,7 @@ export async function raceWithTimeout<T>(
294
294
  ): Promise<"completed" | "timed_out"> {
295
295
  let timer: ReturnType<typeof setTimeout> | undefined;
296
296
  try {
297
- const result = await Promise.race([
297
+ return await Promise.race([
298
298
  promise.then(
299
299
  () => "completed" as const,
300
300
  () => "completed" as const,
@@ -303,7 +303,6 @@ export async function raceWithTimeout<T>(
303
303
  timer = setTimeout(() => resolve("timed_out"), timeoutMs);
304
304
  }),
305
305
  ]);
306
- return result;
307
306
  } finally {
308
307
  if (timer !== undefined) {
309
308
  clearTimeout(timer);
@@ -118,6 +118,7 @@ import { getLogger } from "../util/logger.js";
118
118
  import { withSqliteRetry } from "../util/sqlite-retry.js";
119
119
  import type { WorkspaceGitService } from "../workspace/git-service.js";
120
120
  import type { commitTurnChanges } from "../workspace/turn-commit.js";
121
+ import { trackDaemonActivity, turnActivityLabel } from "./activity-trail.js";
121
122
  import type { AssistantAttachmentDraft } from "./assistant-attachments.js";
122
123
  import { BrowserModeSessionProducer } from "./browser-mode-session.js";
123
124
  import { ComputerUseModeSessionProducer } from "./computer-use-mode-session.js";
@@ -3558,12 +3559,16 @@ export class Conversation {
3558
3559
  },
3559
3560
  ): Promise<void> {
3560
3561
  const { onEvent, ...rest } = options ?? {};
3561
- return runAgentLoopImpl(
3562
- this,
3563
- content,
3564
- userMessageId,
3565
- onEvent ?? this.emit,
3566
- rest,
3562
+ return trackDaemonActivity(
3563
+ turnActivityLabel(this, options?.callSite, options?.isInteractive),
3564
+ () =>
3565
+ runAgentLoopImpl(
3566
+ this,
3567
+ content,
3568
+ userMessageId,
3569
+ onEvent ?? this.emit,
3570
+ rest,
3571
+ ),
3567
3572
  );
3568
3573
  }
3569
3574
 
@@ -6,11 +6,112 @@
6
6
  * a healthy loop never reports.
7
7
  * - `start`/`stop` must be idempotent and safe to call in any order so daemon
8
8
  * startup and the two shutdown paths can call them unconditionally.
9
+ * - `buildBlockTelemetryDetail` must never send conversation titles and must
10
+ * always fit the server's detail byte cap, since an oversize bag drops the
11
+ * whole report.
9
12
  */
10
13
  import { describe, expect, test } from "bun:test";
11
14
 
12
- const { evaluateTick, startEventLoopWatchdog, stopEventLoopWatchdog } =
13
- await import("./event-loop-watchdog.js");
15
+ import type { StallCapture } from "../monitoring/stall-capture.js";
16
+ import type { SectionTrailEntry } from "../persistence/slow-sync-log.js";
17
+ import type { DaemonActivityGroup } from "./activity-trail.js";
18
+
19
+ const {
20
+ buildBlockTelemetryDetail,
21
+ detailFitsServerCap,
22
+ evaluateTick,
23
+ startEventLoopWatchdog,
24
+ stopEventLoopWatchdog,
25
+ } = await import("./event-loop-watchdog.js");
26
+
27
+ function captureWith(conversationCount: number): StallCapture {
28
+ return {
29
+ ts: 1,
30
+ daemonPid: 1,
31
+ heartbeatAgeMs: 21_000,
32
+ kernelStack: "[<0>] do_epoll_wait+0x4a0/0x4e0\n".repeat(12),
33
+ processState: "S",
34
+ waitState: {
35
+ syscall: 232,
36
+ epoll: {
37
+ fd: 7,
38
+ watched: 1,
39
+ listeningSockets: 0,
40
+ sockets: 0,
41
+ pipes: 1,
42
+ eventfds: 0,
43
+ timerfds: 0,
44
+ other: 0,
45
+ },
46
+ children: [{ pid: 200, comm: "git", state: "R", ageMs: 20_000 }],
47
+ threads: Array.from({ length: 8 }, (_, i) => ({
48
+ comm: `Bun Pool ${i}`,
49
+ state: "S",
50
+ wchan: "futex_wait_queue",
51
+ count: 1,
52
+ })),
53
+ },
54
+ sample: {
55
+ ts: 1,
56
+ memory: null,
57
+ memoryStat: null,
58
+ reclaim: null,
59
+ cpu: null,
60
+ events: null,
61
+ deltas: null,
62
+ disk: null,
63
+ processes: [
64
+ ...Array.from({ length: 6 }, (_, i) => ({
65
+ pid: 100 + i,
66
+ name: i === 0 ? "memory-worker" : `chrome-${i}`,
67
+ cpuPct: 150 - i * 10,
68
+ rssMb: 800,
69
+ })),
70
+ { pid: 1, name: "daemon", cpuPct: 4, rssMb: 900 },
71
+ ],
72
+ activeConversations: Array.from(
73
+ { length: conversationCount },
74
+ (_, i) => ({
75
+ conversationId: `01a0c9a0-0000-7000-8000-00000000000${i % 10}`,
76
+ title:
77
+ "A long title drawn from the user's own first message x".repeat(2),
78
+ originChannel: "vellum",
79
+ originInterface: "web",
80
+ processingStartedAt: 1,
81
+ }),
82
+ ),
83
+ },
84
+ };
85
+ }
86
+
87
+ /** The report's maximum of 10 activity groups, longest (a user turn) first. */
88
+ const fullActivity: DaemonActivityGroup[] = [
89
+ {
90
+ kind: "turn",
91
+ conversationType: "standard",
92
+ callSite: "mainAgent",
93
+ turnInterface: "web",
94
+ interactive: true,
95
+ count: 1,
96
+ running: 1,
97
+ longestMs: 90_000,
98
+ },
99
+ ...Array.from({ length: 9 }, (_, i) => ({
100
+ kind: "turn" as const,
101
+ conversationType: "background" as const,
102
+ callSite: "heartbeatAgent" as const,
103
+ interactive: false,
104
+ count: i + 1,
105
+ running: 0,
106
+ longestMs: 60_000 - i,
107
+ })),
108
+ ];
109
+
110
+ const trail: SectionTrailEntry[] = Array.from({ length: 16 }, (_, i) => ({
111
+ label: "conversation-crud:get-messages",
112
+ startedAgoMs: 100_000 + i * 1_000,
113
+ endedAgoMs: 99_000 + i * 1_000,
114
+ }));
14
115
 
15
116
  describe("evaluateTick", () => {
16
117
  const INTERVAL = 1_000;
@@ -78,3 +179,101 @@ describe("start/stop lifecycle", () => {
78
179
  expect(() => stopEventLoopWatchdog()).not.toThrow();
79
180
  });
80
181
  });
182
+
183
+ describe("buildBlockTelemetryDetail", () => {
184
+ test("never sends conversation titles", () => {
185
+ // GIVEN a capture whose active conversations carry titles
186
+ // WHEN the telemetry detail is built
187
+ const detail = buildBlockTelemetryDetail({
188
+ thresholdMs: 5_000,
189
+ daemonUptimeMs: 60_000,
190
+ activity: [],
191
+ sectionTrail: [],
192
+ stallCapture: captureWith(2),
193
+ });
194
+ // THEN every title is nulled but the rest of the entry is kept
195
+ const conversations = detail.stall_capture!.sample.activeConversations!;
196
+ expect(conversations).toHaveLength(2);
197
+ expect(conversations.every((c) => c.title === null)).toBe(true);
198
+ expect(conversations[0]!.originInterface).toBe("web");
199
+ });
200
+
201
+ test("does not mutate the caller's capture", () => {
202
+ // GIVEN a capture the caller also logs locally
203
+ const capture = captureWith(2);
204
+ // WHEN the telemetry detail is built
205
+ buildBlockTelemetryDetail({
206
+ thresholdMs: 5_000,
207
+ daemonUptimeMs: 60_000,
208
+ activity: [],
209
+ sectionTrail: trail,
210
+ stallCapture: capture,
211
+ });
212
+ // THEN the local copy still has its titles
213
+ expect(capture.sample.activeConversations![0]!.title).not.toBeNull();
214
+ });
215
+
216
+ test("a small report is sent untrimmed", () => {
217
+ // GIVEN a report well under the byte cap
218
+ // WHEN the telemetry detail is built
219
+ const detail = buildBlockTelemetryDetail({
220
+ thresholdMs: 5_000,
221
+ daemonUptimeMs: 60_000,
222
+ activity: [],
223
+ sectionTrail: trail.slice(0, 2),
224
+ stallCapture: captureWith(1),
225
+ });
226
+ // THEN nothing is trimmed and the wait state survives intact
227
+ expect(detail.trimmed).toBeUndefined();
228
+ expect(detail.stall_capture!.waitState!.threads).toHaveLength(8);
229
+ });
230
+
231
+ test("an oversize report is trimmed to fit, keeping the wait state", () => {
232
+ // GIVEN a full trail and many active conversations (the heavy-user case)
233
+ // WHEN the telemetry detail is built
234
+ const detail = buildBlockTelemetryDetail({
235
+ thresholdMs: 5_000,
236
+ daemonUptimeMs: 60_000,
237
+ activity: fullActivity,
238
+ sectionTrail: trail,
239
+ stallCapture: captureWith(20),
240
+ });
241
+ // THEN it fits the server cap, records what was trimmed, and still
242
+ // carries the attribution the report exists for
243
+ expect(detailFitsServerCap(detail)).toBe(true);
244
+ expect(detail.trimmed!.length).toBeGreaterThan(0);
245
+ expect(detail.stall_capture!.waitState!.epoll!.pipes).toBe(1);
246
+ expect(detail.stall_capture!.waitState!.children).toHaveLength(1);
247
+ expect(detail.activity.length).toBeGreaterThanOrEqual(4);
248
+ expect(
249
+ detail.stall_capture!.sample.processes!.length,
250
+ ).toBeGreaterThanOrEqual(3);
251
+ expect(detail.stall_capture!.sample.processes![0]!.name).toBe(
252
+ "memory-worker",
253
+ );
254
+ // Trimming keeps the daemon for comparison.
255
+ expect(
256
+ detail.stall_capture!.sample.processes!.some((p) => p.name === "daemon"),
257
+ ).toBe(true);
258
+ expect(detail.activity[0]).toMatchObject({
259
+ kind: "turn",
260
+ conversationType: "standard",
261
+ });
262
+ });
263
+ });
264
+
265
+ describe("detailFitsServerCap", () => {
266
+ test("ignores undefined keys, which serialization drops", () => {
267
+ // GIVEN a small detail with an undefined field
268
+ // WHEN it is checked against the server cap
269
+ // THEN the undefined key does not count as an invalid payload
270
+ expect(detailFitsServerCap({ a: 1, b: undefined })).toBe(true);
271
+ });
272
+
273
+ test("rejects a detail over the server cap", () => {
274
+ // GIVEN a detail larger than 4096 serialized bytes
275
+ // WHEN it is checked against the server cap
276
+ // THEN it does not fit
277
+ expect(detailFitsServerCap({ blob: "x".repeat(5_000) })).toBe(false);
278
+ });
279
+ });