@oneuptime/common 12.0.0 → 12.0.1
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/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +12 -2
- package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
- package/Server/Services/LlmProviderService.ts +69 -9
- package/Server/Services/RunnerService.ts +1 -7
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
- package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
- package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
- package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
- package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
- package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
- package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
- package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
- package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
- package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
- package/Types/Runbook/RunbookStep.ts +15 -0
- package/Types/Runner/RunnerLiveStatus.ts +114 -0
- package/UI/Components/Detail/Detail.tsx +155 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
- package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
- package/build/dist/Server/Services/LlmProviderService.js +61 -8
- package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
- package/build/dist/Server/Services/RunnerService.js +1 -6
- package/build/dist/Server/Services/RunnerService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
- package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
- package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
- package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
- package/build/dist/UI/Components/Detail/Detail.js +97 -0
- package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import LlmProviderService from "../../../Server/Services/LlmProviderService";
|
|
2
|
+
import LlmProvider from "../../../Models/DatabaseModels/LlmProvider";
|
|
3
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
4
|
+
import Select from "../../../Server/Types/Database/Select";
|
|
5
|
+
import { describe, expect, test, afterEach } from "@jest/globals";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* isProviderUsableByProject answers "may this project run against this
|
|
9
|
+
* provider id?" for callers that hold a caller-supplied id — today the
|
|
10
|
+
* runbook AI step's optional provider pin, whose id comes out of an
|
|
11
|
+
* unvalidated JSON column.
|
|
12
|
+
*
|
|
13
|
+
* The invariant: a project may use a global provider or one it owns, and
|
|
14
|
+
* nothing else. A provider carries an apiKey, so a yes here for another
|
|
15
|
+
* project's row would hand project A the use of project B's key.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const projectId: ObjectID = ObjectID.generate();
|
|
19
|
+
const otherProjectId: ObjectID = ObjectID.generate();
|
|
20
|
+
const providerId: ObjectID = ObjectID.generate();
|
|
21
|
+
|
|
22
|
+
function fakeProvider(overrides: Partial<LlmProvider> = {}): LlmProvider {
|
|
23
|
+
return {
|
|
24
|
+
id: providerId,
|
|
25
|
+
...overrides,
|
|
26
|
+
} as unknown as LlmProvider;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("LlmProviderService.isProviderUsableByProject", () => {
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
jest.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("accepts a provider the project owns", async () => {
|
|
35
|
+
jest
|
|
36
|
+
.spyOn(LlmProviderService, "findOneBy")
|
|
37
|
+
.mockResolvedValue(fakeProvider({ projectId, isGlobalLlm: false }));
|
|
38
|
+
|
|
39
|
+
await expect(
|
|
40
|
+
LlmProviderService.isProviderUsableByProject({
|
|
41
|
+
projectId,
|
|
42
|
+
llmProviderId: providerId,
|
|
43
|
+
}),
|
|
44
|
+
).resolves.toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("accepts a global provider, which is shared with every project", async () => {
|
|
48
|
+
jest
|
|
49
|
+
.spyOn(LlmProviderService, "findOneBy")
|
|
50
|
+
.mockResolvedValue(fakeProvider({ isGlobalLlm: true }));
|
|
51
|
+
|
|
52
|
+
await expect(
|
|
53
|
+
LlmProviderService.isProviderUsableByProject({
|
|
54
|
+
projectId,
|
|
55
|
+
llmProviderId: providerId,
|
|
56
|
+
}),
|
|
57
|
+
).resolves.toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("rejects a provider owned by a different project", async () => {
|
|
61
|
+
jest.spyOn(LlmProviderService, "findOneBy").mockResolvedValue(
|
|
62
|
+
fakeProvider({
|
|
63
|
+
projectId: otherProjectId,
|
|
64
|
+
isGlobalLlm: false,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
LlmProviderService.isProviderUsableByProject({
|
|
70
|
+
projectId,
|
|
71
|
+
llmProviderId: providerId,
|
|
72
|
+
}),
|
|
73
|
+
).resolves.toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("rejects a provider that no longer exists", async () => {
|
|
77
|
+
jest.spyOn(LlmProviderService, "findOneBy").mockResolvedValue(null);
|
|
78
|
+
|
|
79
|
+
await expect(
|
|
80
|
+
LlmProviderService.isProviderUsableByProject({
|
|
81
|
+
projectId,
|
|
82
|
+
llmProviderId: providerId,
|
|
83
|
+
}),
|
|
84
|
+
).resolves.toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("rejects a provider with no owner and no global flag", async () => {
|
|
88
|
+
/*
|
|
89
|
+
* A row that is neither global nor owned matches nobody. Treating an
|
|
90
|
+
* absent projectId as a match would make an orphaned row usable by every
|
|
91
|
+
* project at once.
|
|
92
|
+
*/
|
|
93
|
+
jest
|
|
94
|
+
.spyOn(LlmProviderService, "findOneBy")
|
|
95
|
+
.mockResolvedValue(fakeProvider({ isGlobalLlm: false }));
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
LlmProviderService.isProviderUsableByProject({
|
|
99
|
+
projectId,
|
|
100
|
+
llmProviderId: providerId,
|
|
101
|
+
}),
|
|
102
|
+
).resolves.toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("rejects a non-uuid id without ever touching the database", async () => {
|
|
106
|
+
/*
|
|
107
|
+
* _id is a uuid column: querying it with garbage is a Postgres cast
|
|
108
|
+
* error, not an empty result. The id arrives from an unvalidated JSON
|
|
109
|
+
* config, so the shape check has to happen before the query — otherwise
|
|
110
|
+
* a typo in a runbook surfaces as a driver error during an incident.
|
|
111
|
+
*/
|
|
112
|
+
const findSpy: jest.SpiedFunction<typeof LlmProviderService.findOneBy> =
|
|
113
|
+
jest.spyOn(LlmProviderService, "findOneBy");
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
LlmProviderService.isProviderUsableByProject({
|
|
117
|
+
projectId,
|
|
118
|
+
llmProviderId: new ObjectID("not-a-uuid"),
|
|
119
|
+
}),
|
|
120
|
+
).resolves.toBe(false);
|
|
121
|
+
|
|
122
|
+
expect(findSpy).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("rejects an empty id without touching the database", async () => {
|
|
126
|
+
const findSpy: jest.SpiedFunction<typeof LlmProviderService.findOneBy> =
|
|
127
|
+
jest.spyOn(LlmProviderService, "findOneBy");
|
|
128
|
+
|
|
129
|
+
await expect(
|
|
130
|
+
LlmProviderService.isProviderUsableByProject({
|
|
131
|
+
projectId,
|
|
132
|
+
llmProviderId: new ObjectID(""),
|
|
133
|
+
}),
|
|
134
|
+
).resolves.toBe(false);
|
|
135
|
+
|
|
136
|
+
expect(findSpy).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("never selects the apiKey — a yes/no must not load secrets", async () => {
|
|
140
|
+
const findSpy: jest.SpiedFunction<typeof LlmProviderService.findOneBy> =
|
|
141
|
+
jest
|
|
142
|
+
.spyOn(LlmProviderService, "findOneBy")
|
|
143
|
+
.mockResolvedValue(fakeProvider({ projectId }));
|
|
144
|
+
|
|
145
|
+
await LlmProviderService.isProviderUsableByProject({
|
|
146
|
+
projectId,
|
|
147
|
+
llmProviderId: providerId,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const select: Select<LlmProvider> = findSpy.mock.calls[0]![0]!
|
|
151
|
+
.select as Select<LlmProvider>;
|
|
152
|
+
expect(select["apiKey"]).toBeUndefined();
|
|
153
|
+
expect(select["projectId"]).toBe(true);
|
|
154
|
+
expect(select["isGlobalLlm"]).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("looks the provider up by id alone, so a global row is reachable", async () => {
|
|
158
|
+
/*
|
|
159
|
+
* Scoping the query by projectId would silently exclude global
|
|
160
|
+
* providers (their projectId is NULL) — the tenant decision belongs in
|
|
161
|
+
* the check on the loaded row, not in the query.
|
|
162
|
+
*/
|
|
163
|
+
const findSpy: jest.SpiedFunction<typeof LlmProviderService.findOneBy> =
|
|
164
|
+
jest
|
|
165
|
+
.spyOn(LlmProviderService, "findOneBy")
|
|
166
|
+
.mockResolvedValue(fakeProvider({ isGlobalLlm: true }));
|
|
167
|
+
|
|
168
|
+
await LlmProviderService.isProviderUsableByProject({
|
|
169
|
+
projectId,
|
|
170
|
+
llmProviderId: providerId,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const query: Record<string, unknown> = findSpy.mock.calls[0]![0]!
|
|
174
|
+
.query as Record<string, unknown>;
|
|
175
|
+
expect(query["_id"]).toBe(providerId.toString());
|
|
176
|
+
expect(query["projectId"]).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
* getProviderForChat shares the same usability predicate. Its contract is the
|
|
182
|
+
* opposite of the runbook step's on failure — a human is watching a chat, so
|
|
183
|
+
* an unusable id falls back to the default instead of erroring — and these
|
|
184
|
+
* tests pin that difference down so a future refactor of the shared predicate
|
|
185
|
+
* cannot quietly swap one behaviour for the other.
|
|
186
|
+
*/
|
|
187
|
+
describe("LlmProviderService.getProviderForChat", () => {
|
|
188
|
+
afterEach(() => {
|
|
189
|
+
jest.restoreAllMocks();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("uses an explicitly chosen provider the project owns", async () => {
|
|
193
|
+
const chosen: LlmProvider = fakeProvider({ projectId });
|
|
194
|
+
const findSpy: jest.SpiedFunction<typeof LlmProviderService.findOneBy> =
|
|
195
|
+
jest.spyOn(LlmProviderService, "findOneBy").mockResolvedValue(chosen);
|
|
196
|
+
|
|
197
|
+
const result: LlmProvider | null =
|
|
198
|
+
await LlmProviderService.getProviderForChat({
|
|
199
|
+
projectId,
|
|
200
|
+
llmProviderId: providerId,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(result).toBe(chosen);
|
|
204
|
+
expect(findSpy).toHaveBeenCalledTimes(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("uses an explicitly chosen global provider", async () => {
|
|
208
|
+
const chosen: LlmProvider = fakeProvider({ isGlobalLlm: true });
|
|
209
|
+
jest.spyOn(LlmProviderService, "findOneBy").mockResolvedValue(chosen);
|
|
210
|
+
|
|
211
|
+
await expect(
|
|
212
|
+
LlmProviderService.getProviderForChat({
|
|
213
|
+
projectId,
|
|
214
|
+
llmProviderId: providerId,
|
|
215
|
+
}),
|
|
216
|
+
).resolves.toBe(chosen);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("falls back to the project default when the chosen provider belongs to another project", async () => {
|
|
220
|
+
const foreign: LlmProvider = fakeProvider({ projectId: otherProjectId });
|
|
221
|
+
const projectDefault: LlmProvider = fakeProvider({ projectId });
|
|
222
|
+
jest
|
|
223
|
+
.spyOn(LlmProviderService, "findOneBy")
|
|
224
|
+
.mockResolvedValueOnce(foreign)
|
|
225
|
+
.mockResolvedValueOnce(projectDefault);
|
|
226
|
+
|
|
227
|
+
const result: LlmProvider | null =
|
|
228
|
+
await LlmProviderService.getProviderForChat({
|
|
229
|
+
projectId,
|
|
230
|
+
llmProviderId: providerId,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(result).toBe(projectDefault);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("falls back when the chosen provider no longer exists", async () => {
|
|
237
|
+
const projectDefault: LlmProvider = fakeProvider({ projectId });
|
|
238
|
+
jest
|
|
239
|
+
.spyOn(LlmProviderService, "findOneBy")
|
|
240
|
+
.mockResolvedValueOnce(null)
|
|
241
|
+
.mockResolvedValueOnce(projectDefault);
|
|
242
|
+
|
|
243
|
+
await expect(
|
|
244
|
+
LlmProviderService.getProviderForChat({
|
|
245
|
+
projectId,
|
|
246
|
+
llmProviderId: providerId,
|
|
247
|
+
}),
|
|
248
|
+
).resolves.toBe(projectDefault);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("a non-uuid choice falls back instead of hitting the database with it", async () => {
|
|
252
|
+
/*
|
|
253
|
+
* Without the shape check this query is a Postgres cast error rather
|
|
254
|
+
* than a miss, so a stale or hand-edited id would break a whole
|
|
255
|
+
* conversation instead of quietly resolving to the default.
|
|
256
|
+
*/
|
|
257
|
+
const projectDefault: LlmProvider = fakeProvider({ projectId });
|
|
258
|
+
const findSpy: jest.SpiedFunction<typeof LlmProviderService.findOneBy> =
|
|
259
|
+
jest
|
|
260
|
+
.spyOn(LlmProviderService, "findOneBy")
|
|
261
|
+
.mockResolvedValue(projectDefault);
|
|
262
|
+
|
|
263
|
+
const result: LlmProvider | null =
|
|
264
|
+
await LlmProviderService.getProviderForChat({
|
|
265
|
+
projectId,
|
|
266
|
+
llmProviderId: new ObjectID("not-a-uuid"),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(result).toBe(projectDefault);
|
|
270
|
+
// Only the default-resolution query ran — never one keyed on the garbage id.
|
|
271
|
+
for (const call of findSpy.mock.calls) {
|
|
272
|
+
expect(
|
|
273
|
+
(call[0]!.query as Record<string, unknown>)["_id"],
|
|
274
|
+
).toBeUndefined();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("with no choice at all it goes straight to default resolution", async () => {
|
|
279
|
+
const projectDefault: LlmProvider = fakeProvider({ projectId });
|
|
280
|
+
const findSpy: jest.SpiedFunction<typeof LlmProviderService.findOneBy> =
|
|
281
|
+
jest
|
|
282
|
+
.spyOn(LlmProviderService, "findOneBy")
|
|
283
|
+
.mockResolvedValue(projectDefault);
|
|
284
|
+
|
|
285
|
+
await expect(
|
|
286
|
+
LlmProviderService.getProviderForChat({ projectId }),
|
|
287
|
+
).resolves.toBe(projectDefault);
|
|
288
|
+
|
|
289
|
+
const query: Record<string, unknown> = findSpy.mock.calls[0]![0]!
|
|
290
|
+
.query as Record<string, unknown>;
|
|
291
|
+
expect(query["isDefault"]).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { ToolArgs } from "../../../../Server/Utils/AI/Toolbox/ToolTypes";
|
|
2
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
3
|
+
import ObjectID from "../../../../Types/ObjectID";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* ToolArgs.scopeServiceIds and getTimeRange already have dedicated suites.
|
|
7
|
+
* This covers the remaining argument extractors — the coercion, trimming,
|
|
8
|
+
* clamping and defaulting rules the AI toolbox relies on to turn loosely-typed
|
|
9
|
+
* model-supplied JSON arguments into safe, typed values.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
describe("ToolArgs extractors", () => {
|
|
13
|
+
describe("getString", () => {
|
|
14
|
+
test("trims and returns a non-empty string", () => {
|
|
15
|
+
expect(ToolArgs.getString({ q: " hello " }, "q")).toBe("hello");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("returns undefined for empty or whitespace-only strings", () => {
|
|
19
|
+
expect(ToolArgs.getString({ q: "" }, "q")).toBeUndefined();
|
|
20
|
+
expect(ToolArgs.getString({ q: " " }, "q")).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns undefined for missing keys and non-string values", () => {
|
|
24
|
+
expect(ToolArgs.getString({}, "q")).toBeUndefined();
|
|
25
|
+
expect(ToolArgs.getString({ q: 5 }, "q")).toBeUndefined();
|
|
26
|
+
expect(ToolArgs.getString({ q: null }, "q")).toBeUndefined();
|
|
27
|
+
expect(ToolArgs.getString({ q: ["a"] }, "q")).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("getStringArray", () => {
|
|
32
|
+
test("keeps only non-empty strings, trimming them", () => {
|
|
33
|
+
expect(
|
|
34
|
+
ToolArgs.getStringArray({ ids: ["a", " b ", "", " ", "c"] }, "ids"),
|
|
35
|
+
).toEqual(["a", " b ", "c"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("filters out non-string members", () => {
|
|
39
|
+
expect(
|
|
40
|
+
ToolArgs.getStringArray(
|
|
41
|
+
{ ids: ["a", 1, true, null, "b"] as unknown as Array<string> },
|
|
42
|
+
"ids",
|
|
43
|
+
),
|
|
44
|
+
).toEqual(["a", "b"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns undefined when nothing survives filtering", () => {
|
|
48
|
+
expect(
|
|
49
|
+
ToolArgs.getStringArray({ ids: ["", " "] }, "ids"),
|
|
50
|
+
).toBeUndefined();
|
|
51
|
+
expect(ToolArgs.getStringArray({ ids: [] }, "ids")).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns undefined for a non-array value", () => {
|
|
55
|
+
expect(ToolArgs.getStringArray({ ids: "a,b" }, "ids")).toBeUndefined();
|
|
56
|
+
expect(ToolArgs.getStringArray({}, "ids")).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("getNumber", () => {
|
|
61
|
+
const options: { defaultValue: number; min: number; max: number } = {
|
|
62
|
+
defaultValue: 10,
|
|
63
|
+
min: 1,
|
|
64
|
+
max: 100,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
test("passes finite numbers through, flooring fractionals", () => {
|
|
68
|
+
expect(ToolArgs.getNumber({ n: 5 }, "n", options)).toBe(5);
|
|
69
|
+
expect(ToolArgs.getNumber({ n: 5.9 }, "n", options)).toBe(5);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("parses numeric strings", () => {
|
|
73
|
+
expect(ToolArgs.getNumber({ n: "7" }, "n", options)).toBe(7);
|
|
74
|
+
expect(ToolArgs.getNumber({ n: "7.8" }, "n", options)).toBe(7);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("clamps to the configured min and max", () => {
|
|
78
|
+
expect(ToolArgs.getNumber({ n: 1000 }, "n", options)).toBe(100);
|
|
79
|
+
expect(ToolArgs.getNumber({ n: -5 }, "n", options)).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("falls back to the default for missing or invalid values", () => {
|
|
83
|
+
expect(ToolArgs.getNumber({}, "n", options)).toBe(10);
|
|
84
|
+
expect(ToolArgs.getNumber({ n: "abc" }, "n", options)).toBe(10);
|
|
85
|
+
expect(ToolArgs.getNumber({ n: NaN }, "n", options)).toBe(10);
|
|
86
|
+
expect(ToolArgs.getNumber({ n: Infinity }, "n", options)).toBe(10);
|
|
87
|
+
expect(ToolArgs.getNumber({ n: "" }, "n", options)).toBe(10);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("still clamps the default value itself", () => {
|
|
91
|
+
// Default below min is raised to min; default above max lowered to max.
|
|
92
|
+
expect(
|
|
93
|
+
ToolArgs.getNumber({}, "n", { defaultValue: 0, min: 5, max: 100 }),
|
|
94
|
+
).toBe(5);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("getBoolean", () => {
|
|
99
|
+
test("returns real booleans as-is", () => {
|
|
100
|
+
expect(ToolArgs.getBoolean({ b: true }, "b")).toBe(true);
|
|
101
|
+
expect(ToolArgs.getBoolean({ b: false }, "b")).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("coerces the strings 'true' and 'false'", () => {
|
|
105
|
+
expect(ToolArgs.getBoolean({ b: "true" }, "b")).toBe(true);
|
|
106
|
+
expect(ToolArgs.getBoolean({ b: "false" }, "b")).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns undefined for anything else", () => {
|
|
110
|
+
expect(ToolArgs.getBoolean({ b: "TRUE" }, "b")).toBeUndefined();
|
|
111
|
+
expect(ToolArgs.getBoolean({ b: 1 }, "b")).toBeUndefined();
|
|
112
|
+
expect(ToolArgs.getBoolean({ b: "yes" }, "b")).toBeUndefined();
|
|
113
|
+
expect(ToolArgs.getBoolean({}, "b")).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("getObjectID", () => {
|
|
118
|
+
test("wraps a non-empty string as an ObjectID", () => {
|
|
119
|
+
const id: ObjectID | undefined = ToolArgs.getObjectID(
|
|
120
|
+
{ serviceId: " abc123 " },
|
|
121
|
+
"serviceId",
|
|
122
|
+
);
|
|
123
|
+
expect(id).toBeInstanceOf(ObjectID);
|
|
124
|
+
// getString trims first, so the ObjectID carries the trimmed value.
|
|
125
|
+
expect(id?.toString()).toBe("abc123");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("returns undefined for missing or empty values", () => {
|
|
129
|
+
expect(ToolArgs.getObjectID({}, "serviceId")).toBeUndefined();
|
|
130
|
+
expect(
|
|
131
|
+
ToolArgs.getObjectID({ serviceId: " " }, "serviceId"),
|
|
132
|
+
).toBeUndefined();
|
|
133
|
+
expect(
|
|
134
|
+
ToolArgs.getObjectID(
|
|
135
|
+
{ serviceId: 5 as unknown as string } as JSONObject,
|
|
136
|
+
"serviceId",
|
|
137
|
+
),
|
|
138
|
+
).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import WidgetBuilder from "../../../../../Server/Utils/AI/Toolbox/WidgetBuilder";
|
|
2
|
+
import { JSONObject } from "../../../../../Types/JSON";
|
|
3
|
+
import {
|
|
4
|
+
AIChatCitationTarget,
|
|
5
|
+
AIChatWidget,
|
|
6
|
+
AIChatWidgetColumn,
|
|
7
|
+
AIChatWidgetSeries,
|
|
8
|
+
AIChatWidgetSpan,
|
|
9
|
+
AIChatWidgetStat,
|
|
10
|
+
AIChatWidgetType,
|
|
11
|
+
} from "../../../../../Types/AI/AIChatTypes";
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* WidgetBuilder is the factory the AI toolbox uses to attach inline widgets to
|
|
15
|
+
* a tool result. The shapes it produces are a contract with the frontend
|
|
16
|
+
* renderer, so this pins down the widget type, the data envelope, the defaults
|
|
17
|
+
* (bars.stacked / bars.xIsTime), and the invariant that id/citationId are left
|
|
18
|
+
* blank for ChatAgentRunner to mint.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const link: AIChatCitationTarget = {
|
|
22
|
+
// A minimal citation target; only its presence is asserted below.
|
|
23
|
+
} as AIChatCitationTarget;
|
|
24
|
+
|
|
25
|
+
describe("WidgetBuilder", () => {
|
|
26
|
+
test("table() builds a Table widget carrying columns and rows", () => {
|
|
27
|
+
const columns: Array<AIChatWidgetColumn> = [
|
|
28
|
+
{ key: "name", title: "Name" } as AIChatWidgetColumn,
|
|
29
|
+
];
|
|
30
|
+
const rows: Array<JSONObject> = [{ name: "api" }, { name: "worker" }];
|
|
31
|
+
|
|
32
|
+
const widget: AIChatWidget = WidgetBuilder.table({
|
|
33
|
+
title: "Services",
|
|
34
|
+
description: "All services",
|
|
35
|
+
columns,
|
|
36
|
+
rows,
|
|
37
|
+
link,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(widget.type).toBe(AIChatWidgetType.Table);
|
|
41
|
+
expect(widget.title).toBe("Services");
|
|
42
|
+
expect(widget.description).toBe("All services");
|
|
43
|
+
expect(widget.id).toBe("");
|
|
44
|
+
expect((widget.data as JSONObject)["columns"]).toBe(columns);
|
|
45
|
+
expect((widget.data as JSONObject)["rows"]).toBe(rows);
|
|
46
|
+
expect((widget.data as JSONObject)["link"]).toBe(link);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("timeSeries() hardcodes xIsTime to true", () => {
|
|
50
|
+
const series: Array<AIChatWidgetSeries> = [
|
|
51
|
+
{ name: "cpu", points: [] } as unknown as AIChatWidgetSeries,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const widget: AIChatWidget = WidgetBuilder.timeSeries({
|
|
55
|
+
title: "CPU over time",
|
|
56
|
+
series,
|
|
57
|
+
unit: "%",
|
|
58
|
+
valueLabel: "CPU",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(widget.type).toBe(AIChatWidgetType.TimeSeriesChart);
|
|
62
|
+
const data: JSONObject = widget.data as JSONObject;
|
|
63
|
+
expect(data["xIsTime"]).toBe(true);
|
|
64
|
+
expect(data["unit"]).toBe("%");
|
|
65
|
+
expect(data["valueLabel"]).toBe("CPU");
|
|
66
|
+
expect(data["series"]).toBe(series);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("bars() defaults stacked and xIsTime to false", () => {
|
|
70
|
+
const series: Array<AIChatWidgetSeries> = [
|
|
71
|
+
{ name: "logs", points: [] } as unknown as AIChatWidgetSeries,
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const widget: AIChatWidget = WidgetBuilder.bars({
|
|
75
|
+
title: "Log volume",
|
|
76
|
+
series,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(widget.type).toBe(AIChatWidgetType.BarChart);
|
|
80
|
+
const data: JSONObject = widget.data as JSONObject;
|
|
81
|
+
expect(data["stacked"]).toBe(false);
|
|
82
|
+
expect(data["xIsTime"]).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("bars() honors explicit stacked and xIsTime flags", () => {
|
|
86
|
+
const widget: AIChatWidget = WidgetBuilder.bars({
|
|
87
|
+
title: "Log volume by severity",
|
|
88
|
+
series: [],
|
|
89
|
+
stacked: true,
|
|
90
|
+
xIsTime: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const data: JSONObject = widget.data as JSONObject;
|
|
94
|
+
expect(data["stacked"]).toBe(true);
|
|
95
|
+
expect(data["xIsTime"]).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("bars() keeps a `false` flag rather than replacing it with the default", () => {
|
|
99
|
+
/*
|
|
100
|
+
* Regression guard for the `?? false` nullish coalescing: an explicit
|
|
101
|
+
* false must survive, and is indistinguishable from the default here —
|
|
102
|
+
* the point is that passing false does not throw or flip to true.
|
|
103
|
+
*/
|
|
104
|
+
const widget: AIChatWidget = WidgetBuilder.bars({
|
|
105
|
+
title: "x",
|
|
106
|
+
series: [],
|
|
107
|
+
stacked: false,
|
|
108
|
+
xIsTime: false,
|
|
109
|
+
});
|
|
110
|
+
const data: JSONObject = widget.data as JSONObject;
|
|
111
|
+
expect(data["stacked"]).toBe(false);
|
|
112
|
+
expect(data["xIsTime"]).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("traceWaterfall() carries spans and total duration", () => {
|
|
116
|
+
const spans: Array<AIChatWidgetSpan> = [
|
|
117
|
+
{ name: "GET /", durationMs: 12 } as unknown as AIChatWidgetSpan,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const widget: AIChatWidget = WidgetBuilder.traceWaterfall({
|
|
121
|
+
title: "Trace",
|
|
122
|
+
spans,
|
|
123
|
+
totalDurationMs: 120,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(widget.type).toBe(AIChatWidgetType.TraceWaterfall);
|
|
127
|
+
const data: JSONObject = widget.data as JSONObject;
|
|
128
|
+
expect(data["spans"]).toBe(spans);
|
|
129
|
+
expect(data["totalDurationMs"]).toBe(120);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("incidentList / alertList / exceptionList wrap items under the right type", () => {
|
|
133
|
+
const items: Array<JSONObject> = [{ id: "1" }];
|
|
134
|
+
|
|
135
|
+
const incident: AIChatWidget = WidgetBuilder.incidentList({
|
|
136
|
+
title: "Open incidents",
|
|
137
|
+
items,
|
|
138
|
+
});
|
|
139
|
+
expect(incident.type).toBe(AIChatWidgetType.IncidentList);
|
|
140
|
+
expect((incident.data as JSONObject)["items"]).toBe(items);
|
|
141
|
+
|
|
142
|
+
const alert: AIChatWidget = WidgetBuilder.alertList({
|
|
143
|
+
title: "Open alerts",
|
|
144
|
+
items,
|
|
145
|
+
});
|
|
146
|
+
expect(alert.type).toBe(AIChatWidgetType.AlertList);
|
|
147
|
+
expect((alert.data as JSONObject)["items"]).toBe(items);
|
|
148
|
+
|
|
149
|
+
const exception: AIChatWidget = WidgetBuilder.exceptionList({
|
|
150
|
+
title: "Exceptions",
|
|
151
|
+
items,
|
|
152
|
+
});
|
|
153
|
+
expect(exception.type).toBe(AIChatWidgetType.ExceptionList);
|
|
154
|
+
expect((exception.data as JSONObject)["items"]).toBe(items);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("resourceCard() maps its heading/subheading/fields", () => {
|
|
158
|
+
const fields: Array<{ label: string; value: string }> = [
|
|
159
|
+
{ label: "State", value: "Investigating" },
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
const widget: AIChatWidget = WidgetBuilder.resourceCard({
|
|
163
|
+
title: "Incident",
|
|
164
|
+
resourceType: "Incident",
|
|
165
|
+
heading: "API is down",
|
|
166
|
+
subheading: "SEV1",
|
|
167
|
+
fields,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(widget.type).toBe(AIChatWidgetType.ResourceCard);
|
|
171
|
+
const data: JSONObject = widget.data as JSONObject;
|
|
172
|
+
expect(data["resourceType"]).toBe("Incident");
|
|
173
|
+
expect(data["heading"]).toBe("API is down");
|
|
174
|
+
expect(data["subheading"]).toBe("SEV1");
|
|
175
|
+
expect(data["fields"]).toBe(fields);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("stats() builds StatCards", () => {
|
|
179
|
+
const stats: Array<AIChatWidgetStat> = [
|
|
180
|
+
{ label: "Errors", value: "42" } as unknown as AIChatWidgetStat,
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const widget: AIChatWidget = WidgetBuilder.stats({
|
|
184
|
+
title: "KPIs",
|
|
185
|
+
stats,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(widget.type).toBe(AIChatWidgetType.StatCards);
|
|
189
|
+
expect((widget.data as JSONObject)["stats"]).toBe(stats);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("every builder leaves id blank for the runner to mint", () => {
|
|
193
|
+
const widgets: Array<AIChatWidget> = [
|
|
194
|
+
WidgetBuilder.table({ title: "t", columns: [], rows: [] }),
|
|
195
|
+
WidgetBuilder.timeSeries({ title: "t", series: [] }),
|
|
196
|
+
WidgetBuilder.bars({ title: "t", series: [] }),
|
|
197
|
+
WidgetBuilder.incidentList({ title: "t", items: [] }),
|
|
198
|
+
WidgetBuilder.stats({ title: "t", stats: [] }),
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
for (const widget of widgets) {
|
|
202
|
+
expect(widget.id).toBe("");
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|