1import type {
2 RelayAgentActivityAggregateState,
3 RelayAgentActivityState,
4} from "@t3tools/contracts/relay";
5import * as DateTime from "effect/DateTime";
6import * as Option from "effect/Option";
7import {
8 isExpiredAgentActivityState,
9 isTerminalPhase,
10 MAX_ACTIVITY_ROWS,
11 sanitizeAgentActivityAggregateState,
12} from "./agentActivityPayloads.ts";
13
14export function statusForPhase(phase: RelayAgentActivityState["phase"]): string {
15 switch (phase) {
16 case "waiting_for_approval":
17 return "Approval";
18 case "waiting_for_input":
19 return "Input";
20 case "completed":
21 return "Done";
22 case "failed":
23 return "Failed";
24 case "starting":
25 // Matches the web sidebar's pill wording (Sidebar.logic.ts) so the same
26 // thread reads the same across surfaces.
27 return "Connecting";
28 case "running":
29 return "Working";
30 case "stale":
31 return "Waiting";
32 }
33}
34
35function aggregateRowForState(state: RelayAgentActivityState) {
36 return {
37 environmentId: state.environmentId,
38 threadId: state.threadId,
39 projectTitle: state.projectTitle,
40 threadTitle: state.threadTitle,
41 modelTitle: state.modelTitle,
42 phase: state.phase,
43 status: statusForPhase(state.phase),
44 updatedAt: state.updatedAt,
45 deepLink: state.deepLink,
46 };
47}
48
49function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActivityAggregateState {
50 return sanitizeAgentActivityAggregateState({
51 title: "T3 Code",
52 subtitle: state.phase === "failed" ? "Agent work failed" : "Agent work completed",
53 activeCount: 0,
54 updatedAt: state.updatedAt,
55 activities: [aggregateRowForState(state)],
56 });
57}
58
59// How long a finished thread keeps its Done/Failed row in the aggregate while
60// other agents are still active. Long enough to be seen on the lock screen,
61// short enough that the activity list stays about live work.
62export const TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS = 15 * 60 * 1_000;
63
64function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): boolean {
65 if (!isTerminalPhase(state)) {
66 return false;
67 }
68 const updatedAtMs = Option.match(DateTime.make(state.updatedAt), {
69 onNone: () => Number.NaN,
70 onSome: (dt) => dt.epochMilliseconds,
71 });
72 if (Number.isNaN(updatedAtMs)) {
73 return false;
74 }
75 return nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS;
76}
77
78export function makeAggregateState(input: {
79 readonly activeStates: ReadonlyArray<RelayAgentActivityState>;
80 readonly terminalState: RelayAgentActivityState | null;
81 readonly nowMs: number;
82}): RelayAgentActivityAggregateState | null {
83 const activeStates = input.activeStates.filter(
84 (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs),
85 );
86 if (activeStates.length === 0) {
87 if (input.terminalState !== null) {
88 return terminalAggregateState(input.terminalState);
89 }
90 // With no live work, recently finished threads keep the card showing
91 // Done/Failed content (an armed card never renders an empty state). The
92 // newly-terminal alert rules key off the previously delivered aggregate,
93 // so replays repaint this without buzzing. Once the terminal rows age
94 // out, the aggregate is null and the delivery layer ends the card.
95 const recentTerminal = input.activeStates
96 .filter((state) => isRecentTerminalState(state, input.nowMs))
97 .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
98 const newest = recentTerminal[0];
99 if (!newest) {
100 return null;
101 }
102 return sanitizeAgentActivityAggregateState({
103 title: "T3 Code",
104 subtitle: newest.phase === "failed" ? "Agent work failed" : "Agent work completed",
105 activeCount: 0,
106 updatedAt: newest.updatedAt,
107 activities: recentTerminal.slice(0, MAX_ACTIVITY_ROWS).map(aggregateRowForState),
108 });
109 }
110 // Recently finished threads ride along after the active ones (display slots
111 // permitting) so a completion is visible as Done/Failed instead of the row
112 // silently vanishing while other agents keep the activity alive.
113 const recentTerminalStates = input.activeStates
114 .filter((state) => isRecentTerminalState(state, input.nowMs))
115 .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
116 const displayedStates = [
117 ...activeStates
118 .toSorted((a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase))
119 .slice(0, MAX_ACTIVITY_ROWS),
120 ...recentTerminalStates,
121 ].slice(0, MAX_ACTIVITY_ROWS);
122 const updatedAt = [...activeStates, ...recentTerminalStates].reduce((latest, state) =>
123 state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest,
124 ).updatedAt;
125 return sanitizeAgentActivityAggregateState({
126 title: "T3 Code",
127 subtitle: "Agent work in progress",
128 activeCount: activeStates.length,
129 updatedAt,
130 activities: displayedStates.map(aggregateRowForState),
131 });
132}
133
134export function activityPhasePriority(phase: RelayAgentActivityState["phase"]): number {
135 if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0;
136 if (phase === "failed") return 1;
137 if (phase === "starting" || phase === "running") return 2;
138 return 3;
139}