1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import type { RelayAgentActivityState } from "@t3tools/contracts/relay";
4import { makeAggregateState } from "./agentActivityAggregate.ts";
5import {
6 attentionTransitionRows,
7 terminalTransitionRows,
8 shouldAlertForActivity,
9} from "./agentActivityAlerts.ts";
10
11const state: RelayAgentActivityState = {
12 environmentId: EnvironmentId.make("env"),
13 threadId: ThreadId.make("thread"),
14 projectTitle: "Project",
15 threadTitle: "Thread",
16 modelTitle: "Model",
17 headline: "Working",
18 phase: "running",
19 updatedAt: "1970-01-01T00:00:00.000Z",
20 deepLink: "/threads/env/thread",
21};
22const preferences = {
23 notificationsEnabled: true,
24 liveActivitiesEnabled: true,
25 notifyOnApproval: true,
26 notifyOnInput: true,
27 notifyOnCompletion: true,
28 notifyOnFailure: true,
29};
30const aggregate = (states: RelayAgentActivityState[]) =>
31 makeAggregateState({ activeStates: states, terminalState: null, nowMs: 0 })!;
32
33describe("shared agent activity policy", () => {
34 it.each(["waiting_for_approval", "waiting_for_input"] as const)(
35 "keeps an older %s ahead of five running rows",
36 (phase) => {
37 const running = Array.from({ length: 5 }, (_, i) => ({
38 ...state,
39 threadId: ThreadId.make(`running-${i}`),
40 }));
41 const waiting = { ...state, phase, updatedAt: "1969-12-31T23:59:00.000Z" };
42 const next = aggregate([...running, waiting]);
43 expect(next.activities[0]?.threadId).toBe(state.threadId);
44 expect(next.activeCount).toBe(6);
45 expect(next.activities).toHaveLength(5);
46 expect(
47 attentionTransitionRows({
48 previousAggregate: aggregate(running),
49 nextAggregate: next,
50 preferences,
51 }),
52 ).toMatchObject([{ threadId: state.threadId }]);
53 },
54 );
55
56 it("allows fresh unobserved completions for events, but not reconciliation or repeats", () => {
57 const previousAggregate = aggregate([
58 { ...state, threadId: ThreadId.make("old"), phase: "completed" },
59 ]);
60 const nextAggregate = aggregate([{ ...state, phase: "completed" }]);
61 const input = { previousAggregate, nextAggregate, preferences, nowMs: 0 };
62 expect(terminalTransitionRows(input)).toEqual([]);
63 expect(terminalTransitionRows({ ...input, includeUnobserved: true })).toHaveLength(1);
64 expect(
65 terminalTransitionRows({
66 ...input,
67 includeUnobserved: true,
68 previousAggregate: nextAggregate,
69 }),
70 ).toEqual([]);
71 expect(terminalTransitionRows({ ...input, includeUnobserved: true, nowMs: 180_000 })).toEqual(
72 [],
73 );
74 });
75
76 it("distinguishes equal thread IDs across environments", () => {
77 const waiting = { ...state, phase: "waiting_for_input" as const };
78 const other = { ...waiting, environmentId: EnvironmentId.make("other") };
79 expect(
80 attentionTransitionRows({
81 previousAggregate: aggregate([waiting]),
82 nextAggregate: aggregate([waiting, other]),
83 preferences,
84 }),
85 ).toMatchObject([{ environmentId: other.environmentId }]);
86 });
87
88 it("checks current permission, event preferences, and freshness together", () => {
89 const input = { ...state, phase: "completed" as const, preferences, nowMs: 0 };
90 expect(shouldAlertForActivity(input)).toBe(true);
91 expect(
92 shouldAlertForActivity({
93 ...input,
94 preferences: { ...preferences, notificationsEnabled: false },
95 }),
96 ).toBe(false);
97 expect(
98 shouldAlertForActivity({
99 ...input,
100 preferences: { ...preferences, notifyOnCompletion: false },
101 }),
102 ).toBe(false);
103 expect(shouldAlertForActivity({ ...input, nowMs: 180_000 })).toBe(false);
104 });
105});