1import type { RelayAgentActivityState, RelayDeliveryResult } from "@t3tools/contracts/relay";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import * as AgentActivityRows from "./AgentActivityRows.ts";
7import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
8import * as LiveActivities from "./LiveActivities.ts";
9import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
10import * as FcmDeliveries from "./FcmDeliveries.ts";
11import * as ApnsDeliveries from "./ApnsDeliveries.ts";
12
13const layerPublisher = AgentActivityPublisher.layer.pipe(
14 Layer.provide(
15 Layer.succeed(FcmDeliveries.FcmDeliveries, {
16 enqueue: () => Effect.succeed(null),
17 process: () => Effect.void,
18 }),
19 ),
20);
21
22const state: RelayAgentActivityState = {
23 environmentId: "env" as RelayAgentActivityState["environmentId"],
24 threadId: "thread" as RelayAgentActivityState["threadId"],
25 projectTitle: "Project",
26 threadTitle: "Thread",
27 modelTitle: "gpt-5.4",
28 phase: "running",
29 headline: "Running",
30 updatedAt: "1970-01-01T00:00:00.000Z",
31 deepLink: "/threads/env/thread",
32};
33
34function target(deviceId: string): LiveActivities.TargetRow {
35 return {
36 user_id: "dev:julius",
37 device_id: deviceId,
38 platform: "ios",
39 ios_major_version: 18,
40 app_version: "1.0.0",
41 bundle_id: null,
42 aps_environment: null,
43 push_token: null,
44 push_to_start_token: "start-token",
45 preferences_json: "{}",
46 activity_push_token: null,
47 remote_start_queued_at: null,
48 remote_started_at: null,
49 ended_at: null,
50 last_aggregate_json: null,
51 last_live_activity_delivery_at: null,
52 };
53}
54
55function makeLiveActivities(
56 overrides: Partial<LiveActivities.LiveActivities["Service"]> = {},
57): LiveActivities.LiveActivities["Service"] {
58 return {
59 register: () => Effect.void,
60 listTargets: () => Effect.succeed([]),
61 markDelivery: () => Effect.void,
62 markStartQueued: () => Effect.void,
63 clearStartQueued: () => Effect.void,
64 invalidateDeliveryToken: () => Effect.void,
65 ...overrides,
66 };
67}
68
69function makeAgentActivityRows(
70 overrides: Partial<AgentActivityRows.AgentActivityRows["Service"]> = {},
71): AgentActivityRows.AgentActivityRows["Service"] {
72 return {
73 upsert: () => Effect.void,
74 remove: () => Effect.void,
75 pruneTerminal: () => Effect.void,
76 listForUser: () => Effect.succeed([state]),
77 getForUserThread: () => Effect.succeed(state),
78 ...overrides,
79 };
80}
81
82function makeEnvironmentLinks(
83 overrides: Partial<EnvironmentLinks.EnvironmentLinks["Service"]> = {},
84): EnvironmentLinks.EnvironmentLinks["Service"] {
85 return {
86 upsert: () => Effect.void,
87 listDeliveryUsersForEnvironment: () =>
88 Effect.succeed([
89 {
90 userId: "dev:julius",
91 notificationsEnabled: true,
92 liveActivitiesEnabled: true,
93 },
94 ]),
95 listForUser: () => Effect.succeed([]),
96 getForUser: () => Effect.succeed(null),
97 findActiveManagedForEnvironment: () => Effect.succeed([]),
98 setHoldWebhooksWhileOffline: () => Effect.void,
99 revokeForUser: () => Effect.succeed(false),
100 ...overrides,
101 };
102}
103
104function makeApnsDeliveries(
105 overrides: Partial<ApnsDeliveries.ApnsDeliveries["Service"]> = {},
106): ApnsDeliveries.ApnsDeliveries["Service"] {
107 return {
108 sendForTarget: () => Effect.succeed(null),
109 sendPushNotificationForTarget: () => Effect.succeed(null),
110 sendLiveActivity: () =>
111 Effect.succeed({
112 deviceId: "device",
113 kind: "live_activity_start",
114 ok: true,
115 apnsStatus: 200,
116 apnsReason: null,
117 apnsId: "apns-id",
118 }),
119 sendPushNotification: () =>
120 Effect.succeed({
121 deviceId: "device",
122 kind: "push_notification",
123 ok: true,
124 apnsStatus: 200,
125 apnsReason: null,
126 apnsId: "apns-id",
127 }),
128 processSignedJob: () =>
129 Effect.succeed({
130 deviceId: "device",
131 kind: "live_activity_start",
132 ok: true,
133 apnsStatus: 200,
134 apnsReason: null,
135 apnsId: "apns-id",
136 }),
137 ...overrides,
138 };
139}
140
141describe("AgentActivityPublisher", () => {
142 it.effect("routes Android publication and registration replay to FCM alongside iOS", () => {
143 const android = { ...target("android"), platform: "android" as const, ios_major_version: null };
144 const ios = target("ios");
145 const fcmCalls: Array<Parameters<FcmDeliveries.FcmDeliveries["Service"]["enqueue"]>[0]> = [];
146 const appleDevices: string[] = [];
147 return Effect.gen(function* () {
148 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
149 yield* publisher.publish({
150 environmentId: state.environmentId,
151 environmentPublicKey: "key",
152 threadId: state.threadId,
153 state,
154 });
155 yield* publisher.replayForLiveActivityRegistration({
156 userId: android.user_id,
157 deviceId: android.device_id,
158 });
159 expect(fcmCalls).toEqual([
160 { target: android, state },
161 { target: android, state: null, replay: true },
162 ]);
163 expect(appleDevices).toEqual(["ios"]);
164 }).pipe(
165 Effect.provide(
166 AgentActivityPublisher.layer.pipe(
167 Layer.provide(
168 Layer.mergeAll(
169 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
170 Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()),
171 Layer.succeed(
172 LiveActivities.LiveActivities,
173 makeLiveActivities({ listTargets: () => Effect.succeed([android, ios]) }),
174 ),
175 Layer.succeed(
176 ApnsDeliveries.ApnsDeliveries,
177 makeApnsDeliveries({
178 sendForTarget: (input) =>
179 Effect.sync(() => {
180 appleDevices.push(input.target.device_id);
181 return null;
182 }),
183 }),
184 ),
185 Layer.succeed(FcmDeliveries.FcmDeliveries, {
186 enqueue: (input) =>
187 Effect.sync(() => {
188 fcmCalls.push(input);
189 return null;
190 }),
191 process: () => Effect.void,
192 }),
193 ),
194 ),
195 ),
196 ),
197 );
198 });
199
200 it.effect("replays the latest aggregate when a Live Activity token registers", () => {
201 const registeredTarget: LiveActivities.TargetRow = {
202 ...target("device-1"),
203 push_to_start_token: null,
204 activity_push_token: "activity-token",
205 remote_start_queued_at: null,
206 remote_started_at: "1970-01-01T00:00:01.000Z",
207 };
208 const sent: Array<Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0]> =
209 [];
210 const deliveryResult: RelayDeliveryResult = {
211 deviceId: "device-1",
212 kind: "live_activity_update",
213 ok: true,
214 apnsStatus: null,
215 apnsReason: null,
216 apnsId: "queued",
217 };
218
219 return Effect.gen(function* () {
220 const result = yield* Effect.gen(function* () {
221 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
222 return yield* publisher.replayForLiveActivityRegistration({
223 userId: "dev:julius",
224 deviceId: "device-1",
225 });
226 }).pipe(
227 Effect.provide(
228 layerPublisher.pipe(
229 Layer.provide(
230 Layer.mergeAll(
231 Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()),
232 Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()),
233 Layer.succeed(
234 LiveActivities.LiveActivities,
235 makeLiveActivities({
236 listTargets: () => Effect.succeed([registeredTarget, target("device-2")]),
237 }),
238 ),
239 Layer.succeed(
240 ApnsDeliveries.ApnsDeliveries,
241 makeApnsDeliveries({
242 sendForTarget: (input) =>
243 Effect.sync(() => {
244 sent.push(input);
245 return deliveryResult;
246 }),
247 }),
248 ),
249 ),
250 ),
251 ),
252 ),
253 );
254
255 expect(result).toEqual(deliveryResult);
256 expect(sent).toHaveLength(1);
257 expect(sent[0]?.target.device_id).toBe("device-1");
258 expect(sent[0]?.aggregate).toMatchObject({
259 activeCount: 1,
260 activities: [
261 {
262 environmentId: state.environmentId,
263 threadId: state.threadId,
264 status: "Working",
265 },
266 ],
267 });
268 });
269 });
270
271 it.effect("publishes listed targets through the APNs delivery service", () => {
272 const firstTarget = target("device-1");
273 const secondTarget = target("device-2");
274 const deliveryResult: RelayDeliveryResult = {
275 deviceId: "device-1",
276 kind: "live_activity_start",
277 ok: true,
278 apnsStatus: 200,
279 apnsReason: null,
280 apnsId: "apns-id",
281 };
282 const sentTargets: Array<string> = [];
283 const deliveryLookups: Array<{
284 readonly environmentId: string;
285 readonly environmentPublicKey: string;
286 }> = [];
287 const upserts: Array<Parameters<AgentActivityRows.AgentActivityRows["Service"]["upsert"]>[0]> =
288 [];
289
290 return Effect.gen(function* () {
291 const result = yield* Effect.gen(function* () {
292 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
293 return yield* publisher.publish({
294 environmentId: "env",
295 environmentPublicKey: "environment-public-key",
296 threadId: "thread",
297 state,
298 });
299 }).pipe(
300 Effect.provide(
301 layerPublisher.pipe(
302 Layer.provide(
303 Layer.mergeAll(
304 Layer.succeed(
305 AgentActivityRows.AgentActivityRows,
306 makeAgentActivityRows({
307 upsert: (input) =>
308 Effect.sync(() => {
309 upserts.push(input);
310 }),
311 }),
312 ),
313 Layer.succeed(
314 EnvironmentLinks.EnvironmentLinks,
315 makeEnvironmentLinks({
316 listDeliveryUsersForEnvironment: (input) =>
317 Effect.sync(() => {
318 deliveryLookups.push(input);
319 return [
320 {
321 userId: "dev:julius",
322 notificationsEnabled: true,
323 liveActivitiesEnabled: true,
324 },
325 ];
326 }),
327 }),
328 ),
329 Layer.succeed(
330 LiveActivities.LiveActivities,
331 makeLiveActivities({
332 listTargets: () => Effect.succeed([firstTarget, secondTarget]),
333 }),
334 ),
335 Layer.succeed(
336 ApnsDeliveries.ApnsDeliveries,
337 makeApnsDeliveries({
338 sendForTarget: (input) =>
339 Effect.sync(() => {
340 sentTargets.push(input.target.device_id);
341 return input.target.device_id === "device-1" ? deliveryResult : null;
342 }),
343 }),
344 ),
345 ),
346 ),
347 ),
348 ),
349 );
350
351 expect(sentTargets).toEqual(["device-1", "device-2"]);
352 expect(deliveryLookups).toEqual([
353 {
354 environmentId: "env",
355 environmentPublicKey: "environment-public-key",
356 },
357 ]);
358 expect(upserts).toMatchObject([
359 {
360 environmentPublicKey: "environment-public-key",
361 state: {
362 environmentId: "env",
363 threadId: "thread",
364 },
365 },
366 ]);
367 expect(result).toEqual({ ok: true, deliveries: [deliveryResult] });
368 });
369 });
370
371 it.effect("ends the last remote Live Activity with a terminal content state", () => {
372 const completedState: RelayAgentActivityState = {
373 ...state,
374 phase: "completed",
375 headline: "Done",
376 updatedAt: "1970-01-01T00:00:10.000Z",
377 };
378 const sentAggregates: Array<
379 Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0]
380 > = [];
381 const upserts: Array<Parameters<AgentActivityRows.AgentActivityRows["Service"]["upsert"]>[0]> =
382 [];
383
384 return Effect.gen(function* () {
385 const result = yield* Effect.gen(function* () {
386 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
387 return yield* publisher.publish({
388 environmentId: "env",
389 environmentPublicKey: "environment-public-key",
390 threadId: "thread",
391 state: completedState,
392 });
393 }).pipe(
394 Effect.provide(
395 layerPublisher.pipe(
396 Layer.provide(
397 Layer.mergeAll(
398 Layer.succeed(
399 AgentActivityRows.AgentActivityRows,
400 makeAgentActivityRows({
401 upsert: (input) =>
402 Effect.sync(() => {
403 upserts.push(input);
404 }),
405 listForUser: () => Effect.succeed([]),
406 }),
407 ),
408 Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()),
409 Layer.succeed(
410 LiveActivities.LiveActivities,
411 makeLiveActivities({
412 listTargets: () =>
413 Effect.succeed([
414 {
415 ...target("device-1"),
416 push_to_start_token: null,
417 activity_push_token: "activity-token",
418 remote_started_at: "1970-01-01T00:00:00.000Z",
419 },
420 ]),
421 }),
422 ),
423 Layer.succeed(
424 ApnsDeliveries.ApnsDeliveries,
425 makeApnsDeliveries({
426 sendForTarget: (input) =>
427 Effect.sync(() => {
428 sentAggregates.push(input);
429 return {
430 deviceId: input.target.device_id,
431 kind: "live_activity_end",
432 ok: true,
433 apnsStatus: null,
434 apnsReason: null,
435 apnsId: "queued",
436 };
437 }),
438 }),
439 ),
440 ),
441 ),
442 ),
443 ),
444 );
445
446 expect(result.deliveries).toMatchObject([
447 {
448 deviceId: "device-1",
449 kind: "live_activity_end",
450 ok: true,
451 },
452 ]);
453 // Terminal states are persisted (and later pruned by the cron) so the
454 // finished thread can keep a Done row in later aggregates.
455 expect(upserts).toEqual([
456 {
457 environmentPublicKey: "environment-public-key",
458 state: completedState,
459 },
460 ]);
461 expect(sentAggregates).toHaveLength(1);
462 expect(sentAggregates[0]?.aggregate).toMatchObject({
463 activeCount: 0,
464 subtitle: "Agent work completed",
465 activities: [
466 {
467 environmentId: completedState.environmentId,
468 threadId: completedState.threadId,
469 phase: "completed",
470 status: "Done",
471 },
472 ],
473 });
474 });
475 });
476
477 it.effect("queues push notifications for notification-only environment links", () => {
478 const notificationState: RelayAgentActivityState = {
479 ...state,
480 phase: "waiting_for_input",
481 headline: "Needs input",
482 };
483 const liveAggregates: Array<
484 Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0]
485 > = [];
486 const pushAggregates: Array<
487 Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendPushNotificationForTarget"]>[0]
488 > = [];
489
490 return Effect.gen(function* () {
491 const result = yield* Effect.gen(function* () {
492 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
493 return yield* publisher.publish({
494 environmentId: "env",
495 environmentPublicKey: "environment-public-key",
496 threadId: "thread",
497 state: notificationState,
498 });
499 }).pipe(
500 Effect.provide(
501 layerPublisher.pipe(
502 Layer.provide(
503 Layer.mergeAll(
504 Layer.succeed(
505 AgentActivityRows.AgentActivityRows,
506 makeAgentActivityRows({
507 listForUser: () => Effect.succeed([]),
508 }),
509 ),
510 Layer.succeed(
511 EnvironmentLinks.EnvironmentLinks,
512 makeEnvironmentLinks({
513 listDeliveryUsersForEnvironment: () =>
514 Effect.succeed([
515 {
516 userId: "dev:julius",
517 notificationsEnabled: true,
518 liveActivitiesEnabled: false,
519 },
520 ]),
521 }),
522 ),
523 Layer.succeed(
524 LiveActivities.LiveActivities,
525 makeLiveActivities({
526 listTargets: () =>
527 Effect.succeed([
528 {
529 ...target("device-1"),
530 push_token: "apns-device-token",
531 push_to_start_token: null,
532 },
533 ]),
534 }),
535 ),
536 Layer.succeed(
537 ApnsDeliveries.ApnsDeliveries,
538 makeApnsDeliveries({
539 sendForTarget: (input) =>
540 Effect.sync(() => {
541 liveAggregates.push(input);
542 return null;
543 }),
544 sendPushNotificationForTarget: (input) =>
545 Effect.sync(() => {
546 pushAggregates.push(input);
547 return {
548 deviceId: input.target.device_id,
549 kind: "push_notification",
550 ok: true,
551 queued: true,
552 apnsStatus: null,
553 apnsReason: null,
554 apnsId: null,
555 };
556 }),
557 }),
558 ),
559 ),
560 ),
561 ),
562 ),
563 );
564
565 expect(liveAggregates).toMatchObject([{ aggregate: null }]);
566 expect(pushAggregates).toHaveLength(1);
567 expect(pushAggregates[0]?.aggregate).toMatchObject({
568 activeCount: 1,
569 activities: [
570 {
571 phase: "waiting_for_input",
572 status: "Input",
573 threadId: notificationState.threadId,
574 },
575 ],
576 });
577 expect(result.deliveries).toMatchObject([
578 {
579 deviceId: "device-1",
580 kind: "push_notification",
581 queued: true,
582 },
583 ]);
584 });
585 });
586
587 it.effect(
588 "delivers notifications without querying activity rows when Live Activities are disabled",
589 () => {
590 const notificationState: RelayAgentActivityState = {
591 ...state,
592 phase: "waiting_for_approval",
593 headline: "Needs approval",
594 };
595 const liveAggregates: Array<
596 Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendForTarget"]>[0]
597 > = [];
598 const pushAggregates: Array<
599 Parameters<ApnsDeliveries.ApnsDeliveries["Service"]["sendPushNotificationForTarget"]>[0]
600 > = [];
601
602 return Effect.gen(function* () {
603 const result = yield* Effect.gen(function* () {
604 const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
605 return yield* publisher.publish({
606 environmentId: "env",
607 environmentPublicKey: "environment-public-key",
608 threadId: "thread",
609 state: notificationState,
610 });
611 }).pipe(
612 Effect.provide(
613 layerPublisher.pipe(
614 Layer.provide(
615 Layer.mergeAll(
616 Layer.succeed(
617 AgentActivityRows.AgentActivityRows,
618 makeAgentActivityRows({
619 listForUser: () =>
620 Effect.die("Notification-only delivery must not read rows"),
621 }),
622 ),
623 Layer.succeed(
624 EnvironmentLinks.EnvironmentLinks,
625 makeEnvironmentLinks({
626 listDeliveryUsersForEnvironment: () =>
627 Effect.succeed([
628 {
629 userId: "dev:julius",
630 notificationsEnabled: true,
631 liveActivitiesEnabled: false,
632 },
633 ]),
634 }),
635 ),
636 Layer.succeed(
637 LiveActivities.LiveActivities,
638 makeLiveActivities({
639 listTargets: () =>
640 Effect.succeed([
641 {
642 ...target("device-1"),
643 push_token: "apns-device-token",
644 push_to_start_token: "push-to-start-token",
645 },
646 ]),
647 }),
648 ),
649 Layer.succeed(
650 ApnsDeliveries.ApnsDeliveries,
651 makeApnsDeliveries({
652 sendForTarget: (input) =>
653 Effect.sync(() => {
654 liveAggregates.push(input);
655 return null;
656 }),
657 sendPushNotificationForTarget: (input) =>
658 Effect.sync(() => {
659 pushAggregates.push(input);
660 return {
661 deviceId: input.target.device_id,
662 kind: "push_notification",
663 ok: true,
664 queued: true,
665 apnsStatus: null,
666 apnsReason: null,
667 apnsId: null,
668 };
669 }),
670 }),
671 ),
672 ),
673 ),
674 ),
675 ),
676 );
677
678 expect(liveAggregates).toMatchObject([{ aggregate: null }]);
679 expect(pushAggregates).toHaveLength(1);
680 expect(pushAggregates[0]?.aggregate?.activities).toMatchObject([
681 {
682 environmentId: notificationState.environmentId,
683 threadId: notificationState.threadId,
684 phase: "waiting_for_approval",
685 },
686 ]);
687 expect(result.deliveries).toMatchObject([
688 {
689 deviceId: "device-1",
690 kind: "push_notification",
691 queued: true,
692 },
693 ]);
694 });
695 },
696 );
697});
698
699describe("isExpiredAgentActivityState", () => {
700 const hourMs = 60 * 60 * 1_000;
701
702 it("expires running rows after two hours without an update", () => {
703 expect(AgentActivityPublisher.isExpiredAgentActivityState(state, 2 * hourMs - 1)).toBe(false);
704 expect(AgentActivityPublisher.isExpiredAgentActivityState(state, 2 * hourMs + 1)).toBe(true);
705 });
706
707 it("keeps waiting rows for a day", () => {
708 const waiting: RelayAgentActivityState = { ...state, phase: "waiting_for_approval" };
709 expect(AgentActivityPublisher.isExpiredAgentActivityState(waiting, 23 * hourMs)).toBe(false);
710 expect(AgentActivityPublisher.isExpiredAgentActivityState(waiting, 25 * hourMs)).toBe(true);
711 });
712
713 it("treats rows with unparseable timestamps as expired", () => {
714 expect(
715 AgentActivityPublisher.isExpiredAgentActivityState({ ...state, updatedAt: "not-a-date" }, 0),
716 ).toBe(true);
717 });
718});
719
720describe("makeAggregateState", () => {
721 const hourMs = 60 * 60 * 1_000;
722
723 it("drops expired rows from the aggregate", () => {
724 const fresh: RelayAgentActivityState = {
725 ...state,
726 threadId: "thread-fresh" as RelayAgentActivityState["threadId"],
727 updatedAt: "1970-01-01T03:00:00.000Z",
728 };
729 const aggregate = AgentActivityPublisher.makeAggregateState({
730 activeStates: [state, fresh],
731 terminalState: null,
732 nowMs: 3 * hourMs,
733 });
734
735 expect(aggregate?.activeCount).toBe(1);
736 expect(aggregate?.activities).toMatchObject([{ threadId: "thread-fresh" }]);
737 });
738
739 it("returns null when every row has expired and nothing terminal remains", () => {
740 expect(
741 AgentActivityPublisher.makeAggregateState({
742 activeStates: [state],
743 terminalState: null,
744 nowMs: 3 * hourMs,
745 }),
746 ).toBeNull();
747 });
748
749 it("still reports the terminal state when active rows have expired", () => {
750 const terminalState: RelayAgentActivityState = {
751 ...state,
752 phase: "completed",
753 updatedAt: "1970-01-01T03:00:00.000Z",
754 };
755 const aggregate = AgentActivityPublisher.makeAggregateState({
756 activeStates: [state],
757 terminalState,
758 nowMs: 3 * hourMs,
759 });
760
761 expect(aggregate?.activeCount).toBe(0);
762 expect(aggregate?.activities).toMatchObject([{ phase: "completed" }]);
763 });
764
765 it("keeps a recently finished thread visible as Done beside active agents", () => {
766 const active: RelayAgentActivityState = {
767 ...state,
768 threadId: "thread-active" as RelayAgentActivityState["threadId"],
769 updatedAt: "1970-01-01T00:58:00.000Z",
770 };
771 const justCompleted: RelayAgentActivityState = {
772 ...state,
773 threadId: "thread-done" as RelayAgentActivityState["threadId"],
774 phase: "completed",
775 updatedAt: "1970-01-01T00:59:00.000Z",
776 };
777 const aggregate = AgentActivityPublisher.makeAggregateState({
778 activeStates: [active, justCompleted],
779 terminalState: null,
780 nowMs: hourMs,
781 });
782
783 expect(aggregate?.activeCount).toBe(1);
784 expect(aggregate?.subtitle).toBe("Agent work in progress");
785 expect(aggregate?.activities).toMatchObject([
786 { threadId: "thread-active", phase: "running" },
787 { threadId: "thread-done", phase: "completed", status: "Done" },
788 ]);
789 expect(aggregate?.updatedAt).toBe("1970-01-01T00:59:00.000Z");
790 });
791
792 it("drops finished threads from the aggregate after the display window", () => {
793 const active: RelayAgentActivityState = {
794 ...state,
795 threadId: "thread-active" as RelayAgentActivityState["threadId"],
796 updatedAt: "1970-01-01T00:58:00.000Z",
797 };
798 const staleCompleted: RelayAgentActivityState = {
799 ...state,
800 threadId: "thread-done" as RelayAgentActivityState["threadId"],
801 phase: "completed",
802 updatedAt: "1970-01-01T00:44:00.000Z",
803 };
804 const aggregate = AgentActivityPublisher.makeAggregateState({
805 activeStates: [active, staleCompleted],
806 terminalState: null,
807 nowMs: hourMs,
808 });
809
810 expect(aggregate?.activities).toMatchObject([{ threadId: "thread-active" }]);
811 });
812
813 it("keeps showing recently finished work when nothing is active", () => {
814 const lingeringCompleted: RelayAgentActivityState = {
815 ...state,
816 phase: "completed",
817 updatedAt: "1970-01-01T00:59:00.000Z",
818 };
819 const aggregate = AgentActivityPublisher.makeAggregateState({
820 activeStates: [lingeringCompleted],
821 terminalState: null,
822 nowMs: hourMs,
823 });
824
825 // An armed card never renders an empty state: recently finished threads
826 // keep Done content on it, and once they age out the aggregate becomes
827 // null and the delivery layer ends the card.
828 expect(aggregate).toMatchObject({
829 activeCount: 0,
830 subtitle: "Agent work completed",
831 activities: [{ phase: "completed", status: "Done" }],
832 });
833 expect(
834 AgentActivityPublisher.makeAggregateState({
835 activeStates: [{ ...lingeringCompleted, updatedAt: "1970-01-01T00:44:00.000Z" }],
836 terminalState: null,
837 nowMs: hourMs,
838 }),
839 ).toBeNull();
840 });
841
842 it("gives active agents the display slots before finished ones", () => {
843 const mkActive = (id: string): RelayAgentActivityState => ({
844 ...state,
845 threadId: id as RelayAgentActivityState["threadId"],
846 updatedAt: "1970-01-01T00:58:00.000Z",
847 });
848 const justCompleted: RelayAgentActivityState = {
849 ...state,
850 threadId: "thread-done" as RelayAgentActivityState["threadId"],
851 phase: "completed",
852 updatedAt: "1970-01-01T00:59:00.000Z",
853 };
854 const aggregate = AgentActivityPublisher.makeAggregateState({
855 activeStates: [
856 mkActive("a-1"),
857 mkActive("a-2"),
858 mkActive("a-3"),
859 mkActive("a-4"),
860 mkActive("a-5"),
861 justCompleted,
862 ],
863 terminalState: null,
864 nowMs: hourMs,
865 });
866
867 expect(aggregate?.activeCount).toBe(5);
868 expect(aggregate?.activities).toMatchObject([
869 { threadId: "a-1" },
870 { threadId: "a-2" },
871 { threadId: "a-3" },
872 { threadId: "a-4" },
873 { threadId: "a-5" },
874 ]);
875 });
876});