@skyhook-io/radar-app 1.13.1 → 1.13.2

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.
Files changed (47) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +19 -1
  3. package/src/RadarApp.tsx +2 -2
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +72 -9
  6. package/src/components/diagnose/AISettings.tsx +1 -1
  7. package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
  8. package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
  9. package/src/components/diagnose/DiagnoseContext.tsx +12 -17
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
  11. package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +289 -119
  14. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
  15. package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
  16. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
  17. package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
  18. package/src/components/diagnose/InvestigationView.test.ts +17 -0
  19. package/src/components/diagnose/InvestigationView.tsx +1900 -393
  20. package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
  21. package/src/components/diagnose/agentCatalog.ts +1 -1
  22. package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
  23. package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
  24. package/src/components/diagnose/investigationEvidence.ts +3492 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
  27. package/src/components/diagnose/investigationExplanation.test.ts +63 -0
  28. package/src/components/diagnose/investigationExplanation.ts +22 -0
  29. package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
  30. package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
  31. package/src/components/diagnose/investigationSourceFocus.ts +98 -0
  32. package/src/components/diagnose/investigationState.test.ts +695 -0
  33. package/src/components/diagnose/investigationState.ts +451 -0
  34. package/src/components/diagnose/parts.test.tsx +864 -3
  35. package/src/components/diagnose/parts.tsx +1337 -541
  36. package/src/components/diagnose/target.test.ts +39 -0
  37. package/src/components/diagnose/target.ts +36 -0
  38. package/src/components/diagnose/useDisclosureReveal.ts +117 -0
  39. package/src/components/home/MCPSetupDialog.tsx +2 -2
  40. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  41. package/src/components/home/mcpToolCatalog.ts +3 -2
  42. package/src/components/issues/IssuesPane.tsx +5 -1
  43. package/src/components/settings/SettingsDialog.tsx +11 -13
  44. package/src/components/workload/WorkloadView.tsx +1 -1
  45. package/src/context/DiagnoseCustomization.tsx +11 -8
  46. package/src/index.css +63 -79
  47. package/src/index.ts +1 -1
@@ -0,0 +1,447 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ evidenceDisplaySnapshot,
4
+ groupEvidenceCoverage,
5
+ } from "./investigationEvidencePresentation";
6
+ import {
7
+ evidenceSemanticSnapshot,
8
+ type InvestigationEvidenceData,
9
+ type InvestigationEvidenceLimitation,
10
+ } from "./investigationEvidence";
11
+
12
+ function observation(data: InvestigationEvidenceData) {
13
+ return {
14
+ data,
15
+ title: "Evidence",
16
+ tone: "warning" as const,
17
+ summary: "Finding",
18
+ };
19
+ }
20
+
21
+ const resource = {
22
+ apiVersion: "apps/v1",
23
+ kind: "Deployment",
24
+ metadata: {
25
+ name: "api",
26
+ namespace: "dev",
27
+ uid: "same-object",
28
+ resourceVersion: "1",
29
+ },
30
+ spec: { replicas: 1 },
31
+ status: {
32
+ readyReplicas: 0,
33
+ conditions: [
34
+ {
35
+ type: "Available",
36
+ status: "False",
37
+ reason: "MinimumReplicasUnavailable",
38
+ lastTransitionTime: "2026-09-07T08:00:00Z",
39
+ },
40
+ ],
41
+ },
42
+ };
43
+ const resourceData = (value = resource): InvestigationEvidenceData => ({
44
+ type: "resource",
45
+ resource: value,
46
+ warnings: [],
47
+ });
48
+ const crash = {
49
+ pods: ["api-123"],
50
+ container: "api",
51
+ state: "down",
52
+ reason: "Error",
53
+ exitCode: 1,
54
+ logLine: "2026-09-07T08:00:00.000000000Z Authentication failed",
55
+ logSource: "previous",
56
+ logLineSelection: "fatal_pattern",
57
+ };
58
+ const event = {
59
+ type: "Warning",
60
+ reason: "BackOff",
61
+ message: "Back-off restarting failed container",
62
+ count: 1071,
63
+ lastTimestamp: "2026-09-07T08:00:00Z",
64
+ };
65
+
66
+ describe("evidence display significance (not record identity)", () => {
67
+ it("retains context-only status and issue changes", () => {
68
+ const first = observation({
69
+ type: "resource",
70
+ resource,
71
+ warnings: [],
72
+ resourceContext: {
73
+ tier: "basic",
74
+ statusSummary: { phase: "Pending" },
75
+ issueSummary: { count: 1, topReason: "MissingSecret" },
76
+ },
77
+ });
78
+ for (const patch of [
79
+ { statusSummary: { phase: "Running" } },
80
+ { issueSummary: { count: 1, topReason: "ImagePullBackOff" } },
81
+ ]) {
82
+ if (first.data.type !== "resource") throw new Error("Expected resource");
83
+ const next = observation({
84
+ ...first.data,
85
+ resourceContext: { ...first.data.resourceContext!, ...patch },
86
+ });
87
+ expect(evidenceDisplaySnapshot(first)).not.toBe(
88
+ evidenceDisplaySnapshot(next),
89
+ );
90
+ }
91
+ });
92
+ it("ignores elapsed days in condition warnings", () => {
93
+ const snapshot = (duration: string, age: string) =>
94
+ evidenceDisplaySnapshot(
95
+ observation({
96
+ type: "resource",
97
+ resource,
98
+ warnings: [
99
+ `Condition ` +
100
+ "`Available=False`" +
101
+ ` for ~${duration} (resource age: ${age}).`,
102
+ ],
103
+ }),
104
+ );
105
+ expect(snapshot("1d", "45d")).toBe(snapshot("2d", "46d"));
106
+ });
107
+ it("retains context-only readiness and GitOps changes when the raw object is unchanged", () => {
108
+ const first = observation({
109
+ type: "resource",
110
+ resource,
111
+ warnings: [],
112
+ resourceContext: {
113
+ tier: "basic",
114
+ workloadSummary: { replicas: { desired: 1, ready: 0 } },
115
+ },
116
+ });
117
+ const next = observation({
118
+ type: "resource",
119
+ resource,
120
+ warnings: [],
121
+ resourceContext: {
122
+ tier: "basic",
123
+ workloadSummary: { replicas: { desired: 1, ready: 1 } },
124
+ },
125
+ });
126
+ expect(evidenceDisplaySnapshot(first)).not.toBe(
127
+ evidenceDisplaySnapshot(next),
128
+ );
129
+ const synced = observation({
130
+ type: "resource",
131
+ resource,
132
+ warnings: [],
133
+ gitOpsDiagnosis: { tool: "argocd", sync: "Synced" },
134
+ });
135
+ const outOfSync = observation({
136
+ type: "resource",
137
+ resource,
138
+ warnings: [],
139
+ gitOpsDiagnosis: { tool: "argocd", sync: "OutOfSync" },
140
+ });
141
+ expect(evidenceDisplaySnapshot(synced)).not.toBe(
142
+ evidenceDisplaySnapshot(outOfSync),
143
+ );
144
+ const healthy = observation({
145
+ type: "resource",
146
+ resource: { ...resource, summaryContext: { health: "Healthy" } },
147
+ warnings: [],
148
+ });
149
+ const degraded = observation({
150
+ type: "resource",
151
+ resource: { ...resource, summaryContext: { health: "Degraded" } },
152
+ warnings: [],
153
+ });
154
+ expect(evidenceDisplaySnapshot(healthy)).not.toBe(
155
+ evidenceDisplaySnapshot(degraded),
156
+ );
157
+ });
158
+ it("ignores only elapsed time in the producer's condition warning", () => {
159
+ const first = observation({
160
+ type: "resource",
161
+ resource,
162
+ warnings: ["Condition `Available=False` for ~1m6s (resource age: 46d)."],
163
+ });
164
+ const later = observation({
165
+ type: "resource",
166
+ resource,
167
+ warnings: ["Condition `Available=False` for ~1m7s (resource age: 46d)."],
168
+ });
169
+ const different = observation({
170
+ type: "resource",
171
+ resource,
172
+ warnings: ["A required Secret is missing."],
173
+ });
174
+ expect(evidenceDisplaySnapshot(first)).toBe(evidenceDisplaySnapshot(later));
175
+ expect(evidenceDisplaySnapshot(first)).not.toBe(
176
+ evidenceDisplaySnapshot(different),
177
+ );
178
+ });
179
+ it("tolerates unstructured conditions without deleting their contents", () => {
180
+ const value = {
181
+ ...resource,
182
+ status: { conditions: [null, "unrecognized", { status: "False" }] },
183
+ };
184
+ const snapshot = evidenceDisplaySnapshot(
185
+ observation({ type: "resource", resource: value, warnings: [] }),
186
+ );
187
+ expect(snapshot).toContain('null,"unrecognized"');
188
+ });
189
+ it("ignores bookkeeping and elapsed summaries without changing exact snapshots", () => {
190
+ const first = observation(resourceData());
191
+ const next = observation(
192
+ resourceData({
193
+ ...resource,
194
+ metadata: { ...resource.metadata, resourceVersion: "2" },
195
+ }),
196
+ );
197
+ next.summary = "Unavailable for 1m7s instead of 1m6s";
198
+ expect(evidenceDisplaySnapshot(first)).toBe(evidenceDisplaySnapshot(next));
199
+ expect(evidenceSemanticSnapshot(first)).not.toBe(
200
+ evidenceSemanticSnapshot(next),
201
+ );
202
+ expect(resource.metadata.resourceVersion).toBe("1");
203
+ });
204
+ it("ignores condition collection times, not condition state", () => {
205
+ const first = observation(resourceData());
206
+ const nextResource = {
207
+ ...resource,
208
+ status: {
209
+ ...resource.status,
210
+ conditions: [
211
+ {
212
+ ...resource.status.conditions[0],
213
+ lastTransitionTime: "2026-09-07T08:01:00Z",
214
+ lastUpdateTime: "2026-09-07T08:01:00Z",
215
+ },
216
+ ],
217
+ },
218
+ };
219
+ expect(evidenceDisplaySnapshot(first)).toBe(
220
+ evidenceDisplaySnapshot(observation(resourceData(nextResource))),
221
+ );
222
+ nextResource.status.conditions[0].status = "True";
223
+ expect(evidenceDisplaySnapshot(first)).not.toBe(
224
+ evidenceDisplaySnapshot(observation(resourceData(nextResource))),
225
+ );
226
+ });
227
+ it("retains readiness, configuration, object identity and failure-detail changes", () => {
228
+ for (const next of [
229
+ { ...resource, status: { ...resource.status, readyReplicas: 1 } },
230
+ { ...resource, spec: { replicas: 2 } },
231
+ { ...resource, metadata: { ...resource.metadata, uid: "replacement" } },
232
+ {
233
+ ...resource,
234
+ status: {
235
+ ...resource.status,
236
+ conditions: [
237
+ { ...resource.status.conditions[0], reason: "DifferentFailure" },
238
+ ],
239
+ },
240
+ },
241
+ ])
242
+ expect(evidenceDisplaySnapshot(observation(resourceData()))).not.toBe(
243
+ evidenceDisplaySnapshot(observation(resourceData(next))),
244
+ );
245
+ });
246
+ it("does not strip timestamp-shaped configuration keys", () => {
247
+ const first = observation({
248
+ type: "resource",
249
+ warnings: [],
250
+ resource: {
251
+ ...resource,
252
+ kind: "ConfigMap",
253
+ data: { lastTransitionTime: "before" },
254
+ },
255
+ });
256
+ const next = observation({
257
+ type: "resource",
258
+ warnings: [],
259
+ resource: {
260
+ ...resource,
261
+ kind: "ConfigMap",
262
+ data: { lastTransitionTime: "after" },
263
+ },
264
+ });
265
+ expect(evidenceDisplaySnapshot(first)).not.toBe(
266
+ evidenceDisplaySnapshot(next),
267
+ );
268
+ });
269
+ it("folds recurring crash text across timestamps/instances but preserves raw provenance", () => {
270
+ const first = observation({ type: "crash", namespace: "dev", crash });
271
+ const next = observation({
272
+ type: "crash",
273
+ namespace: "dev",
274
+ crash: {
275
+ ...crash,
276
+ logLine: "2026-09-07T08:06:00.000000000Z Authentication failed",
277
+ logSource: "current",
278
+ },
279
+ });
280
+ expect(evidenceDisplaySnapshot(first)).toBe(evidenceDisplaySnapshot(next));
281
+ expect(evidenceSemanticSnapshot(first)).not.toBe(
282
+ evidenceSemanticSnapshot(next),
283
+ );
284
+ expect(crash.logSource).toBe("previous");
285
+ });
286
+ it("retains a new crash error, exit code, or affected pod", () => {
287
+ const first = observation({ type: "crash", namespace: "dev", crash });
288
+ for (const patch of [
289
+ { logLine: "Connection refused" },
290
+ { exitCode: 137 },
291
+ { pods: ["api-456"] },
292
+ ]) {
293
+ expect(evidenceDisplaySnapshot(first)).not.toBe(
294
+ evidenceDisplaySnapshot(
295
+ observation({
296
+ type: "crash",
297
+ namespace: "dev",
298
+ crash: { ...crash, ...patch },
299
+ }),
300
+ ),
301
+ );
302
+ }
303
+ });
304
+ it("folds event repetition counts, timestamps and ordering", () => {
305
+ const second = { ...event, reason: "Pulling", message: "Pulling image" };
306
+ const first = observation({
307
+ type: "events",
308
+ scope: "dev/api",
309
+ events: [event, second],
310
+ });
311
+ const next = observation({
312
+ type: "events",
313
+ scope: "dev/api",
314
+ events: [
315
+ second,
316
+ { ...event, count: 1074, lastTimestamp: "2026-09-07T08:06:00Z" },
317
+ ],
318
+ });
319
+ expect(evidenceDisplaySnapshot(first)).toBe(evidenceDisplaySnapshot(next));
320
+ expect(evidenceSemanticSnapshot(first)).not.toBe(
321
+ evidenceSemanticSnapshot(next),
322
+ );
323
+ });
324
+ it("retains new event reasons, details, scope and severity", () => {
325
+ const first = observation({
326
+ type: "events",
327
+ scope: "dev/api",
328
+ events: [event],
329
+ });
330
+ for (const patch of [
331
+ { reason: "FailedMount" },
332
+ { message: "New failure detail" },
333
+ { type: "Normal" },
334
+ ]) {
335
+ expect(evidenceDisplaySnapshot(first)).not.toBe(
336
+ evidenceDisplaySnapshot(
337
+ observation({
338
+ type: "events",
339
+ scope: "dev/api",
340
+ events: [{ ...event, ...patch }],
341
+ }),
342
+ ),
343
+ );
344
+ }
345
+ expect(evidenceDisplaySnapshot(first)).not.toBe(
346
+ evidenceDisplaySnapshot(
347
+ observation({ type: "events", scope: "prod/api", events: [event] }),
348
+ ),
349
+ );
350
+ });
351
+ });
352
+
353
+ function limitation(
354
+ source: string,
355
+ message: string,
356
+ kind: InvestigationEvidenceLimitation["kind"],
357
+ history = false,
358
+ ): InvestigationEvidenceLimitation {
359
+ return {
360
+ source,
361
+ message,
362
+ kind,
363
+ ...(history ? { presentation: "history" as const } : {}),
364
+ firstOrder: 0,
365
+ sources: [],
366
+ };
367
+ }
368
+ describe("coverage presentation", () => {
369
+ it.each(["Recent changes", "Container logs", "Issue change correlation"])(
370
+ "does not disguise unsummarizable %s as ordinary sampling",
371
+ (label) => {
372
+ const message =
373
+ "Radar couldn't summarize this investigation step. Review it in Activity.";
374
+ for (const notes of [
375
+ [limitation(label, message, "unknown")],
376
+ [
377
+ limitation(label, "Result limit reached", "truncated"),
378
+ limitation(label, message, "unknown"),
379
+ ],
380
+ ])
381
+ expect(groupEvidenceCoverage(notes)[0].summary).toContain(message);
382
+ },
383
+ );
384
+ it("turns the six overlapping notes into three impact groups, preserving every detail", () => {
385
+ const notes = [
386
+ limitation("Recent changes", "Result limit reached", "truncated"),
387
+ limitation("Recent changes", "Query narrowed", "truncated"),
388
+ limitation(
389
+ "Recent changes",
390
+ "Secret namespace history incomplete",
391
+ "unknown",
392
+ true,
393
+ ),
394
+ limitation(
395
+ "Issue change correlation",
396
+ "Not evaluated for every issue",
397
+ "truncated",
398
+ ),
399
+ limitation(
400
+ "Recent changes",
401
+ "Secret object history incomplete",
402
+ "unknown",
403
+ true,
404
+ ),
405
+ limitation("Container logs", "Log query narrowed", "truncated"),
406
+ ];
407
+ const groups = groupEvidenceCoverage(notes);
408
+ expect(groups.map((g) => [g.label, g.summary])).toEqual([
409
+ [
410
+ "Recent changes",
411
+ "Some changes may be missing; change history is incomplete.",
412
+ ],
413
+ [
414
+ "Issue change correlation",
415
+ "Not all issues were checked against recent changes.",
416
+ ],
417
+ ["Container logs", "Only part of the logs was checked."],
418
+ ]);
419
+ expect(groups.flatMap((g) => g.limitations)).toHaveLength(6);
420
+ expect(groups[0].limitations[0]).toBe(notes[0]);
421
+ });
422
+ it("keeps failed reads explicit and orders them ahead of ordinary limits", () => {
423
+ const groups = groupEvidenceCoverage([
424
+ limitation("Recent changes", "Query narrowed", "truncated"),
425
+ limitation("Container logs", "Logs sampled", "truncated"),
426
+ limitation("Container logs", "Forbidden: cannot read pod logs", "error"),
427
+ limitation("Container logs", "Connection timed out", "error"),
428
+ ]);
429
+ expect(groups[0].hasError).toBe(true);
430
+ expect(groups[0].summary).toContain("Forbidden");
431
+ expect(groups[0].summary).toContain("Connection timed out");
432
+ expect(groups[0].limitations).toHaveLength(3);
433
+ });
434
+ it("keeps history-only neutral and unknown limitations verbatim", () => {
435
+ expect(
436
+ groupEvidenceCoverage([
437
+ limitation("Recent changes", "No durable history", "unknown", true),
438
+ ])[0].historyOnly,
439
+ ).toBe(true);
440
+ expect(
441
+ groupEvidenceCoverage([
442
+ limitation("New producer", "No reliable coverage", "unknown"),
443
+ ])[0].summary,
444
+ ).toBe("No reliable coverage");
445
+ expect(groupEvidenceCoverage([])).toEqual([]);
446
+ });
447
+ });
@@ -0,0 +1,167 @@
1
+ import { parseLogLine } from "../../utils/log-format";
2
+ import {
3
+ evidenceSemanticSnapshot,
4
+ type InvestigationEvidenceObservation,
5
+ type InvestigationEvidenceLimitation,
6
+ } from "./investigationEvidence";
7
+
8
+ /** Display significance only. Never use this to discard records or bind citations. */
9
+ export function evidenceDisplaySnapshot(
10
+ observation: Pick<
11
+ InvestigationEvidenceObservation,
12
+ "data" | "tone" | "title" | "summary"
13
+ >,
14
+ ): string {
15
+ const data = observation.data;
16
+ switch (data.type) {
17
+ case "resource": {
18
+ const { metadata, ...resource } = data.resource;
19
+ const identity = { ...metadata };
20
+ delete identity.resourceVersion;
21
+ delete identity.managedFields;
22
+ const context = data.resourceContext;
23
+ return JSON.stringify({
24
+ type: data.type,
25
+ resource: {
26
+ ...resource,
27
+ metadata: identity,
28
+ status: statusWithoutObservationTimes(resource.status),
29
+ },
30
+ // These summaries can carry live state even when the raw object is cached.
31
+ status: statusWithoutObservationTimes(context?.statusSummary),
32
+ workload: context?.workloadSummary,
33
+ issues: context?.issueSummary,
34
+ gitOps: data.gitOpsDiagnosis,
35
+ warnings: data.warnings.map(warningWithoutElapsedTime),
36
+ });
37
+ }
38
+ case "crash": {
39
+ const { logLine, ...crash } = data.crash;
40
+ return JSON.stringify({
41
+ type: data.type,
42
+ namespace: data.namespace,
43
+ crash: {
44
+ ...crash,
45
+ logSource: undefined,
46
+ logLine: parseLogLine(logLine).content,
47
+ },
48
+ });
49
+ }
50
+ case "events":
51
+ return JSON.stringify({
52
+ type: data.type,
53
+ scope: data.scope,
54
+ // Repetitions and collection time do not constitute a new finding.
55
+ events: data.events
56
+ .map(({ reason, message, type }) =>
57
+ JSON.stringify({ reason, message, type }),
58
+ )
59
+ .sort(),
60
+ });
61
+ default:
62
+ // Unknown evidence types keep their existing conservative comparison.
63
+ return evidenceSemanticSnapshot(observation);
64
+ }
65
+ }
66
+
67
+ // Only condition bookkeeping is normalized; similarly named configuration keys
68
+ // and arbitrary status fields retain their exact values.
69
+ function statusWithoutObservationTimes(status: unknown): unknown {
70
+ if (!status || typeof status !== "object" || Array.isArray(status))
71
+ return status;
72
+ const state = status as Record<string, unknown>;
73
+ if (!Array.isArray(state.conditions)) return status;
74
+ return {
75
+ ...state,
76
+ conditions: state.conditions.map((condition: unknown) => {
77
+ if (
78
+ !condition ||
79
+ typeof condition !== "object" ||
80
+ Array.isArray(condition)
81
+ )
82
+ return condition;
83
+ const finding = { ...(condition as Record<string, unknown>) };
84
+ delete finding.lastTransitionTime;
85
+ delete finding.lastUpdateTime;
86
+ delete finding.lastProbeTime;
87
+ delete finding.lastHeartbeatTime;
88
+ return finding;
89
+ }),
90
+ };
91
+ }
92
+
93
+ function warningWithoutElapsedTime(warning: string): string {
94
+ // Exact warning format from pkg/k8score/object_warnings.go. Preserve the
95
+ // distinction between a recent failure and one present since creation.
96
+ return warning
97
+ .replace(/^(Condition `[^`]+` for ~)[\d.dhms]+/, "$1<elapsed>")
98
+ .replace(/\(resource age: [\d.dhms]+\)\.$/, "(resource age: <elapsed>).");
99
+ }
100
+
101
+ export interface EvidenceCoverageGroup {
102
+ label: string;
103
+ summary: string;
104
+ limitations: InvestigationEvidenceLimitation[];
105
+ hasError: boolean;
106
+ historyOnly: boolean;
107
+ }
108
+
109
+ /** Group the presentation, not the underlying limitations or health qualification. */
110
+ export function groupEvidenceCoverage(
111
+ limitations: InvestigationEvidenceLimitation[],
112
+ ): EvidenceCoverageGroup[] {
113
+ const byLabel = new Map<string, InvestigationEvidenceLimitation[]>();
114
+ for (const limitation of limitations) {
115
+ const entries = byLabel.get(limitation.source) ?? [];
116
+ entries.push(limitation);
117
+ byLabel.set(limitation.source, entries);
118
+ }
119
+ return [...byLabel]
120
+ .map(([label, entries]) => {
121
+ const errors = entries.filter((entry) => entry.kind === "error");
122
+ const historyOnly = entries.every(
123
+ (entry) => entry.kind === "unknown" && entry.presentation === "history",
124
+ );
125
+ const truncated = entries.some((entry) => entry.kind === "truncated");
126
+ const ordinaryLimits = entries.every(
127
+ (entry) =>
128
+ entry.kind === "truncated" ||
129
+ (entry.kind === "unknown" && entry.presentation === "history"),
130
+ );
131
+ let summary: string;
132
+ if (errors.length) {
133
+ // Never replace permission/transport failures with a benign sampling note.
134
+ summary = [...new Set(errors.map((entry) => entry.message))].join(
135
+ " · ",
136
+ );
137
+ } else if (ordinaryLimits && label === "Recent changes") {
138
+ summary = truncated
139
+ ? "Some changes may be missing; change history is incomplete."
140
+ : "Change history is incomplete.";
141
+ } else if (ordinaryLimits && label === "Container logs" && truncated) {
142
+ summary = "Only part of the logs was checked.";
143
+ } else if (
144
+ ordinaryLimits &&
145
+ label === "Issue change correlation" &&
146
+ truncated
147
+ ) {
148
+ summary = "Not all issues were checked against recent changes.";
149
+ } else {
150
+ summary = [...new Set(entries.map((entry) => entry.message))].join(
151
+ " · ",
152
+ );
153
+ }
154
+ return {
155
+ label,
156
+ summary,
157
+ limitations: entries,
158
+ hasError: errors.length > 0,
159
+ historyOnly,
160
+ };
161
+ })
162
+ .sort(
163
+ (a, b) =>
164
+ Number(b.hasError) - Number(a.hasError) ||
165
+ Number(a.historyOnly) - Number(b.historyOnly),
166
+ );
167
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { investigationExplanation } from "./investigationExplanation";
3
+ import type { Turn } from "./parts";
4
+
5
+ const turn = (overrides: Partial<Turn> = {}): Turn => ({
6
+ timeline: [],
7
+ diagnosis: null,
8
+ error: null,
9
+ status: "done",
10
+ ...overrides,
11
+ });
12
+ describe("assessment-local explanation", () => {
13
+ it("does not mistake an ordinary answer for an explanation", () => {
14
+ expect(
15
+ investigationExplanation([turn({ question: "Explain simply" })], 2),
16
+ ).toEqual({ status: "idle" });
17
+ });
18
+ it("restores the answer for its originating assessment without using newer answers", () => {
19
+ const answer = turn({
20
+ explainAssessment: 2,
21
+ diagnosis: { report: "Saved explanation" } as Turn["diagnosis"],
22
+ });
23
+ expect(
24
+ investigationExplanation(
25
+ [answer, turn({ explainAssessment: 8, status: "running" })],
26
+ 2,
27
+ ),
28
+ ).toEqual({ status: "done", text: "Saved explanation" });
29
+ });
30
+ it("restores pending progress and lets the latest retry supersede a failure", () => {
31
+ const failed = turn({
32
+ explainAssessment: 2,
33
+ status: "error",
34
+ error: "Stopped",
35
+ });
36
+ expect(investigationExplanation([failed], 2)).toEqual({
37
+ status: "error",
38
+ error: "Stopped",
39
+ });
40
+ expect(
41
+ investigationExplanation(
42
+ [failed, turn({ explainAssessment: 2, status: "running" })],
43
+ 2,
44
+ ),
45
+ ).toEqual({ status: "running" });
46
+ });
47
+ it("never displays thinking as an answer when the agent returns nothing", () => {
48
+ expect(
49
+ investigationExplanation(
50
+ [
51
+ turn({
52
+ explainAssessment: 2,
53
+ timeline: [{ kind: "thinking", text: "Let me think" }],
54
+ }),
55
+ ],
56
+ 2,
57
+ ),
58
+ ).toEqual({
59
+ status: "error",
60
+ error: "The agent did not return an explanation.",
61
+ });
62
+ });
63
+ });
@@ -0,0 +1,22 @@
1
+ import type { AssessmentExplanation, Turn } from "./parts";
2
+
3
+ export function investigationExplanation(
4
+ turns: readonly Turn[],
5
+ assessmentSequence: number,
6
+ ): AssessmentExplanation {
7
+ const turn = [...turns]
8
+ .reverse()
9
+ .find((turn) => turn.explainAssessment === assessmentSequence);
10
+ if (!turn) return { status: "idle" };
11
+ if (turn.status === "running") return { status: "running" };
12
+ if (turn.status === "error")
13
+ return {
14
+ status: "error",
15
+ error: turn.error || "The explanation could not be completed.",
16
+ };
17
+ const text =
18
+ turn.diagnosis?.report?.trim() || turn.diagnosis?.rootCause?.trim();
19
+ return text
20
+ ? { status: "done", text }
21
+ : { status: "error", error: "The agent did not return an explanation." };
22
+ }