@skyhook-io/radar-app 1.13.4 → 1.14.0

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 (230) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/RadarApp.tsx +5 -0
  4. package/src/api/client.authRedirect.test.ts +12 -1
  5. package/src/api/client.prometheusStatus.test.ts +24 -0
  6. package/src/api/client.ts +325 -16
  7. package/src/api/diagnose.ts +62 -15
  8. package/src/api/drain-plan.test.ts +86 -0
  9. package/src/api/quotas.test.ts +26 -0
  10. package/src/api/quotas.ts +20 -0
  11. package/src/api/workloadMetrics.test.ts +40 -0
  12. package/src/api/workloadMetrics.ts +68 -0
  13. package/src/components/CloudConnectFlow.tsx +179 -42
  14. package/src/components/CloudFunnelButton.tsx +107 -36
  15. package/src/components/ConnectionErrorView.test.tsx +30 -0
  16. package/src/components/ConnectionErrorView.tsx +31 -19
  17. package/src/components/SyncProgressPanel.tsx +73 -0
  18. package/src/components/audit/AuditView.tsx +2 -0
  19. package/src/components/cloudConnectHandoff.test.ts +140 -0
  20. package/src/components/cloudConnectHandoff.ts +124 -0
  21. package/src/components/diagnose/AgentCase.tsx +131 -0
  22. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  23. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  24. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  25. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  26. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  27. package/src/components/diagnose/InvestigationView.tsx +222 -5
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  29. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  30. package/src/components/diagnose/investigationCase.ts +439 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  41. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  42. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  43. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  44. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  45. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  46. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  48. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  49. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  50. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  51. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  52. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  53. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  54. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  55. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  56. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  57. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  58. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  59. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  60. package/src/components/diagnose/investigationMetrics.ts +393 -0
  61. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  62. package/src/components/diagnose/investigationState.test.ts +322 -0
  63. package/src/components/diagnose/investigationState.ts +147 -25
  64. package/src/components/diagnose/parts.test.tsx +482 -0
  65. package/src/components/diagnose/parts.tsx +418 -41
  66. package/src/components/diagnose/target.test.ts +19 -0
  67. package/src/components/diagnose/target.ts +3 -2
  68. package/src/components/gitops/GitOpsView.tsx +7 -0
  69. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  70. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  71. package/src/components/resource/PrometheusCharts.tsx +4 -3
  72. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +245 -103
  74. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  75. package/src/components/resource/RightsizingStrip.tsx +15 -3
  76. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  77. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  78. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  79. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  80. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  81. package/src/components/resource/workloadMetricValues.ts +30 -0
  82. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  83. package/src/components/resources/PodFilePreview.tsx +394 -0
  84. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  85. package/src/components/resources/ResourcesView.tsx +44 -6
  86. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  87. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  88. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  89. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  90. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  91. package/src/components/settings/SettingsDialog.tsx +94 -14
  92. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  93. package/src/components/traffic/TrafficGraph.tsx +6 -6
  94. package/src/components/traffic/TrafficView.tsx +10 -9
  95. package/src/components/traffic/trafficFilters.test.ts +28 -1
  96. package/src/components/traffic/trafficFilters.ts +31 -0
  97. package/src/components/workload/WorkloadView.tsx +19 -8
  98. package/src/context/ConnectionContext.tsx +48 -3
  99. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  100. package/src/context/DiagnoseCustomization.tsx +15 -2
  101. package/src/index.css +17 -0
  102. package/src/index.ts +1 -0
  103. package/src/utils/navigation.ts +1 -1
  104. package/src/utils/shell-safe.test.ts +25 -1
  105. package/src/utils/shell-safe.ts +16 -0
  106. package/src/utils/topology-namespace.test.ts +64 -0
  107. package/src/utils/topology-namespace.ts +18 -0
  108. package/src/components/diagnose/investigationEvidence.test.ts +0 -5202
  109. package/src/components/diagnose/investigationEvidence.ts +0 -4784
  110. package/src/components/gitops/GitOpsActions.tsx +0 -1
  111. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  112. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  113. package/src/components/gitops/SyncCountdown.tsx +0 -1
  114. package/src/components/gitops/index.ts +0 -4
  115. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  116. package/src/components/logs/JsonLogLine.tsx +0 -1
  117. package/src/components/logs/LogCore.tsx +0 -2
  118. package/src/components/logs/useLogBuffer.ts +0 -2
  119. package/src/components/logs/useLogSearch.ts +0 -1
  120. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  121. package/src/components/resources/drawer-components.tsx +0 -1
  122. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  170. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  171. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  172. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  173. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  174. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  175. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  176. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  177. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  178. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  179. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  180. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  181. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  182. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  183. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  184. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  185. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  186. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  187. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  188. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  189. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  190. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  191. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  192. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  193. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  194. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  195. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  196. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  197. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  198. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  199. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  200. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  201. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  202. package/src/components/resources/renderers/index.ts +0 -94
  203. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  204. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  205. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  206. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  207. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  208. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  209. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  210. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  211. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  212. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  213. package/src/components/resources/resource-utils-argo.ts +0 -2
  214. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  215. package/src/components/resources/resource-utils-eso.ts +0 -2
  216. package/src/components/resources/resource-utils-flux.ts +0 -2
  217. package/src/components/resources/resource-utils-istio.ts +0 -2
  218. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  219. package/src/components/resources/resource-utils-knative.ts +0 -2
  220. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  221. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  222. package/src/components/resources/resource-utils-traefik.ts +0 -1
  223. package/src/components/resources/resource-utils-trivy.ts +0 -2
  224. package/src/components/resources/resource-utils-velero.ts +0 -2
  225. package/src/components/shared/EditableYamlView.tsx +0 -24
  226. package/src/components/timeline/DiffViewer.tsx +0 -1
  227. package/src/components/traffic/index.ts +0 -3
  228. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  229. package/src/components/ui/MetricsChart.tsx +0 -1
  230. package/src/components/ui/ResourceBar.tsx +0 -1
@@ -0,0 +1,1568 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { AgentClaimNote, AgentRoleChip } from "./AgentCase";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ InvestigationEvidencePane,
7
+ investigationCaseByGroup,
8
+ investigationEvidenceShouldRevealHistory,
9
+ partitionInvestigationEvidence,
10
+ } from "./InvestigationEvidencePane";
11
+ import {
12
+ projectInvestigationEvidence,
13
+ resolveInvestigationRootCauseEvidence,
14
+ type InvestigationEvidenceProjection,
15
+ type InvestigationEvidenceTimelineItem,
16
+ type InvestigationRootCauseEvidenceResolution,
17
+ } from "./investigationEvidence";
18
+ import {
19
+ resolveInvestigationCase,
20
+ type InvestigationCaseResolution,
21
+ investigationCaseItemsStillRendered,
22
+ mergeInvestigationCases,
23
+ type InvestigationCaseItem,
24
+ } from "./investigationCase";
25
+ import { AssessmentSources, ResultCard, assessmentSourceRows } from "./parts";
26
+ import type { Diagnosis, DiagnosisEvidenceItem } from "../../api/diagnose";
27
+
28
+ const onViewSource = vi.fn();
29
+ const target = {
30
+ kind: "Deployment",
31
+ group: "apps",
32
+ namespace: "shop",
33
+ name: "api",
34
+ };
35
+
36
+ function evidenceRef(scope: string, nonce: string): string {
37
+ return `ev_${scope.repeat(26)}_${nonce.repeat(26)}`;
38
+ }
39
+
40
+ function tool(
41
+ id: string,
42
+ name: string,
43
+ result: unknown,
44
+ patch: Partial<
45
+ Extract<InvestigationEvidenceTimelineItem, { kind: "tool" }>
46
+ > = {},
47
+ ): Extract<InvestigationEvidenceTimelineItem, { kind: "tool" }> {
48
+ return {
49
+ kind: "tool",
50
+ id,
51
+ tool: name,
52
+ status: "done",
53
+ summary: JSON.stringify({
54
+ kind: "deployment",
55
+ namespace: "shop",
56
+ name: "api",
57
+ }),
58
+ result: JSON.stringify(result),
59
+ isError: false,
60
+ radarEvidence: true,
61
+ ...patch,
62
+ };
63
+ }
64
+
65
+ function project(
66
+ ...timeline: InvestigationEvidenceTimelineItem[]
67
+ ): InvestigationEvidenceProjection {
68
+ return projectInvestigationEvidence([{ timeline }], target);
69
+ }
70
+
71
+ function render(
72
+ projection: InvestigationEvidenceProjection,
73
+ investigationCase?: InvestigationCaseResolution,
74
+ rootCauseEvidence?: InvestigationRootCauseEvidenceResolution,
75
+ ): string {
76
+ return renderToStaticMarkup(
77
+ <InvestigationEvidencePane
78
+ projection={projection}
79
+ rootCauseEvidence={rootCauseEvidence}
80
+ investigationCase={investigationCase}
81
+ collecting={false}
82
+ animateGroupIds={new Set()}
83
+ onViewSource={onViewSource}
84
+ onViewActivity={() => {}}
85
+ />,
86
+ );
87
+ }
88
+
89
+ function linked(
90
+ ref: string,
91
+ role: DiagnosisEvidenceItem["role"],
92
+ claim: string,
93
+ subject?: DiagnosisEvidenceItem["subject"],
94
+ ): DiagnosisEvidenceItem {
95
+ return {
96
+ status: "linked",
97
+ ref,
98
+ role,
99
+ claim,
100
+ ...(subject ? { subject } : {}),
101
+ };
102
+ }
103
+
104
+ const deployment = {
105
+ apiVersion: "apps/v1",
106
+ kind: "Deployment",
107
+ metadata: { namespace: "shop", name: "api" },
108
+ status: { readyReplicas: 0, replicas: 1 },
109
+ };
110
+
111
+ const criticalIssue = {
112
+ id: "issue-api-crash",
113
+ severity: "critical",
114
+ source: "problem",
115
+ category: "crashloop",
116
+ category_group: "runtime",
117
+ grouping_scope: "workload",
118
+ kind: "Deployment",
119
+ group: "apps",
120
+ namespace: "shop",
121
+ name: "api",
122
+ reason: "CrashLoopBackOff",
123
+ message: "The API container keeps restarting.",
124
+ };
125
+
126
+ function logs(lines: string[], fallback = false) {
127
+ return {
128
+ lines,
129
+ totalLines: lines.length,
130
+ matchedLines: lines.length,
131
+ fallback,
132
+ };
133
+ }
134
+
135
+ // One diagnose bundle fanning out into a resource card, an issue card, two
136
+ // current log streams and one previous log stream of the same container.
137
+ const diagnoseBundle = {
138
+ resource: deployment,
139
+ resourceContext: {
140
+ tier: "diagnostic",
141
+ issueSummary: {
142
+ count: 1,
143
+ highestSeverity: "critical",
144
+ topReason: "CrashLoopBackOff",
145
+ },
146
+ },
147
+ pods: 1,
148
+ relatedIssues: [criticalIssue],
149
+ logsCurrent: [
150
+ { pod: "api-abc", container: "api", logs: logs(["ERROR auth failed"]) },
151
+ { pod: "api-abc", container: "proxy", logs: logs(["proxy ready"], true) },
152
+ ],
153
+ logsPrevious: [
154
+ {
155
+ pod: "api-abc",
156
+ container: "api",
157
+ logs: logs(["FATAL: MongoServerError: Authentication failed"]),
158
+ },
159
+ ],
160
+ events: [
161
+ {
162
+ reason: "BackOff",
163
+ message: "Back-off restarting failed container",
164
+ type: "Warning",
165
+ count: 4,
166
+ lastTimestamp: "2026-09-02T10:00:00Z",
167
+ },
168
+ ],
169
+ };
170
+
171
+ describe("agent case placement (D-1, D-1b)", () => {
172
+ const ref = evidenceRef("a", "b");
173
+ const projection = project(
174
+ tool("diag", "diagnose", diagnoseBundle, { evidenceRef: ref }),
175
+ );
176
+
177
+ it("places a subject-less item on a fan-out result beside its source, never on a card", () => {
178
+ const resolved = resolveInvestigationCase(
179
+ projection,
180
+ { evidence: [linked(ref, "cause", "Everything in this bundle.")] },
181
+ 0,
182
+ );
183
+ expect(resolved.items).toHaveLength(1);
184
+ expect(resolved.items[0].placement).toBe("source");
185
+ expect(resolved.items[0].groupId).toBeUndefined();
186
+ const html = render(projection, resolved);
187
+ expect(html).not.toContain("Everything in this bundle.");
188
+ expect(html).not.toContain("data-agent-claim");
189
+ });
190
+
191
+ it("pins a subject-bearing item to exactly the observation it names", () => {
192
+ const resolved = resolveInvestigationCase(
193
+ projection,
194
+ {
195
+ evidence: [
196
+ linked(ref, "symptom", "The issue card is the symptom.", {
197
+ kind: "Deployment",
198
+ group: "apps",
199
+ namespace: "shop",
200
+ name: "api",
201
+ observation: "issue",
202
+ }),
203
+ linked(ref, "context", "The Deployment itself looks ordinary.", {
204
+ kind: "deployments",
205
+ name: "api",
206
+ observation: "resource",
207
+ }),
208
+ linked(ref, "context", "Events only repeat the back-off.", {
209
+ kind: "Deployment",
210
+ name: "api",
211
+ observation: "events",
212
+ }),
213
+ linked(
214
+ ref,
215
+ "context",
216
+ "Ambiguous: two observations share this subject.",
217
+ {
218
+ kind: "Deployment",
219
+ name: "api",
220
+ },
221
+ ),
222
+ ],
223
+ },
224
+ 0,
225
+ );
226
+ const byKind = Object.fromEntries(
227
+ resolved.items.map((item) => [
228
+ item.claim,
229
+ {
230
+ placement: item.placement,
231
+ kind: projection.groups.find((group) => group.id === item.groupId)
232
+ ?.kind,
233
+ },
234
+ ]),
235
+ );
236
+ expect(byKind).toEqual({
237
+ "The issue card is the symptom.": { placement: "card", kind: "issue" },
238
+ "The Deployment itself looks ordinary.": {
239
+ placement: "card",
240
+ kind: "resource",
241
+ },
242
+ "Events only repeat the back-off.": { placement: "card", kind: "events" },
243
+ "Ambiguous: two observations share this subject.": {
244
+ placement: "source",
245
+ kind: undefined,
246
+ },
247
+ });
248
+ const html = render(projection, resolved);
249
+ expect(html).toContain("The issue card is the symptom.");
250
+ expect(html).toContain("Events only repeat the back-off.");
251
+ expect(html).not.toContain("Ambiguous: two observations");
252
+ expect(html).toContain(">Symptom<");
253
+ expect(html).toContain(">Context<");
254
+ });
255
+
256
+ it("distinguishes current from previous logs of one container by stream", () => {
257
+ const resolved = resolveInvestigationCase(
258
+ projection,
259
+ {
260
+ evidence: [
261
+ linked(ref, "cause", "The previous instance died on auth.", {
262
+ kind: "Pod",
263
+ namespace: "shop",
264
+ name: "api-abc",
265
+ container: "api",
266
+ stream: "previous",
267
+ }),
268
+ linked(ref, "symptom", "The current instance retries.", {
269
+ kind: "Pod",
270
+ name: "api-abc",
271
+ container: "api",
272
+ stream: "current",
273
+ }),
274
+ linked(ref, "context", "Which stream? Both match.", {
275
+ kind: "Pod",
276
+ name: "api-abc",
277
+ container: "api",
278
+ }),
279
+ linked(ref, "context", "Only one proxy stream exists.", {
280
+ kind: "Pod",
281
+ name: "api-abc",
282
+ container: "proxy",
283
+ }),
284
+ ],
285
+ },
286
+ 0,
287
+ );
288
+ const identities = resolved.items.map((item) => [
289
+ item.placement,
290
+ projection.groups.find((group) => group.id === item.groupId)?.identity,
291
+ ]);
292
+ expect(identities).toEqual([
293
+ ["card", "logs:previous:api-abc:api"],
294
+ ["card", "logs:current:api-abc:api"],
295
+ ["source", undefined],
296
+ ["card", "logs:current:api-abc:proxy"],
297
+ ]);
298
+ });
299
+
300
+ it("binds to the exact earlier read and renders on its revision row, not the card head", () => {
301
+ const first = evidenceRef("a", "c");
302
+ const second = evidenceRef("a", "d");
303
+ const twice = project(
304
+ tool(
305
+ "read-1",
306
+ "get_resource",
307
+ { ...deployment, status: { readyReplicas: 0, replicas: 1 } },
308
+ { evidenceRef: first },
309
+ ),
310
+ tool(
311
+ "read-2",
312
+ "get_resource",
313
+ { ...deployment, status: { readyReplicas: 1, replicas: 1 } },
314
+ { evidenceRef: second },
315
+ ),
316
+ );
317
+ expect(twice.groups).toHaveLength(1);
318
+ const resolved = resolveInvestigationCase(
319
+ twice,
320
+ {
321
+ evidence: [
322
+ linked(first, "cause", "At first read no replica was ready."),
323
+ ],
324
+ ruledOut: [{ hypothesis: "Replicas never recover", evidenceIndex: 0 }],
325
+ },
326
+ 0,
327
+ );
328
+ expect(resolved.items[0].placement).toBe("revision");
329
+ expect(resolved.items[0].observation?.source.stepId).toBe("read-1");
330
+ const html = render(twice, resolved);
331
+ const card = html.slice(html.indexOf("<article"));
332
+ const head = card.slice(0, card.indexOf("Previous observations"));
333
+ expect(head).not.toContain("At first read no replica was ready.");
334
+ expect(head).not.toContain(">Cause<");
335
+ const history = card.slice(card.indexOf("Previous observations"));
336
+ expect(history).toContain("At first read no replica was ready.");
337
+ expect(history).toContain(">Cause<");
338
+ expect(history).toContain(">Initial<");
339
+ expect(history).not.toContain("Used for assessment");
340
+ expect(html).toContain("(earlier observation)");
341
+ });
342
+
343
+ it("keeps an identical earlier read on its own revision row, never folded into the card", () => {
344
+ const first = evidenceRef("a", "c");
345
+ const twice = project(
346
+ tool("read-1", "get_resource", deployment, { evidenceRef: first }),
347
+ tool("read-2", "get_resource", deployment),
348
+ );
349
+ const resolved = resolveInvestigationCase(
350
+ twice,
351
+ { evidence: [linked(first, "context", "Same both times.")] },
352
+ 0,
353
+ );
354
+ expect(resolved.items[0].placement).toBe("revision");
355
+ const html = render(twice, resolved);
356
+ expect(html).toContain("Previous observations · 1");
357
+ expect(html).toContain("Same both times.");
358
+ expect(html).not.toContain("Used for assessment");
359
+ // A reveal aimed at the identical earlier read must open the history that
360
+ // holds its row, which display equivalence alone would keep closed.
361
+ const sourceId = resolved.items[0].observation!.source.id;
362
+ expect(
363
+ investigationEvidenceShouldRevealHistory(twice.groups[0], sourceId),
364
+ ).toBe(false);
365
+ expect(
366
+ investigationEvidenceShouldRevealHistory(
367
+ twice.groups[0],
368
+ sourceId,
369
+ new Set([sourceId]),
370
+ ),
371
+ ).toBe(true);
372
+ });
373
+
374
+ it("keeps every pinned duplicate read as its own row with its own claim", () => {
375
+ const first = evidenceRef("a", "c");
376
+ const second = evidenceRef("a", "d");
377
+ const reads = project(
378
+ tool("read-1", "get_resource", deployment, { evidenceRef: first }),
379
+ tool("read-2", "get_resource", deployment, { evidenceRef: second }),
380
+ tool("read-3", "get_resource", {
381
+ ...deployment,
382
+ status: { readyReplicas: 1, replicas: 1 },
383
+ }),
384
+ );
385
+ const resolved = resolveInvestigationCase(
386
+ reads,
387
+ {
388
+ evidence: [
389
+ linked(first, "symptom", "First read: nothing ready."),
390
+ linked(second, "symptom", "Second read: still nothing ready."),
391
+ ],
392
+ },
393
+ 0,
394
+ );
395
+ expect(resolved.items.map((item) => item.placement)).toEqual([
396
+ "revision",
397
+ "revision",
398
+ ]);
399
+ const html = render(reads, resolved);
400
+ expect(html).toContain("Previous observations · 2");
401
+ expect(html).toContain("First read: nothing ready.");
402
+ expect(html).toContain("Second read: still nothing ready.");
403
+ });
404
+
405
+ it("requires every discriminator both sides state to agree", () => {
406
+ const cases: Array<[DiagnosisEvidenceItem["subject"], string]> = [
407
+ [{ kind: "Deployment", group: "apps", name: "api" }, "card"],
408
+ [{ kind: "Deployment", group: "", name: "api" }, "source"],
409
+ [{ kind: "Deployment", group: "extensions", name: "api" }, "source"],
410
+ [{ kind: "Deployment", namespace: "shop", name: "api" }, "card"],
411
+ [{ kind: "Deployment", namespace: "other", name: "api" }, "source"],
412
+ [{ kind: "Deployment", name: "nope" }, "source"],
413
+ ];
414
+ const single = project(
415
+ tool("read", "get_resource", deployment, { evidenceRef: ref }),
416
+ );
417
+ for (const [subject, placement] of cases) {
418
+ const resolved = resolveInvestigationCase(
419
+ single,
420
+ { evidence: [linked(ref, "context", "c", subject)] },
421
+ 0,
422
+ );
423
+ expect([subject, resolved.items[0].placement]).toEqual([
424
+ subject,
425
+ placement,
426
+ ]);
427
+ }
428
+ // A core Service must not accept a Knative Service subject.
429
+ const service = project(
430
+ tool(
431
+ "svc",
432
+ "get_resource",
433
+ {
434
+ apiVersion: "v1",
435
+ kind: "Service",
436
+ metadata: { namespace: "shop", name: "api" },
437
+ },
438
+ { evidenceRef: ref },
439
+ ),
440
+ );
441
+ expect(
442
+ resolveInvestigationCase(
443
+ service,
444
+ {
445
+ evidence: [
446
+ linked(ref, "cause", "knative", {
447
+ kind: "Service",
448
+ group: "serving.knative.dev",
449
+ namespace: "shop",
450
+ name: "api",
451
+ }),
452
+ ],
453
+ },
454
+ 0,
455
+ ).items[0].placement,
456
+ ).toBe("source");
457
+ // Omitting the container on a multi-container pod is ambiguous.
458
+ expect(
459
+ resolveInvestigationCase(
460
+ projection,
461
+ {
462
+ evidence: [
463
+ linked(ref, "context", "which container?", {
464
+ kind: "Pod",
465
+ name: "api-abc",
466
+ stream: "current",
467
+ }),
468
+ ],
469
+ },
470
+ 0,
471
+ ).items[0].placement,
472
+ ).toBe("source");
473
+ });
474
+ });
475
+
476
+ describe("agent case visibility and ordering (D-2, D-5)", () => {
477
+ const ref = evidenceRef("a", "b");
478
+
479
+ it("keeps a Key adverse card in main when labelled demoted or rules_out, and cannot recolor it", () => {
480
+ const projection = project(
481
+ tool("diag", "diagnose", diagnoseBundle, { evidenceRef: ref }),
482
+ );
483
+ for (const role of ["demoted", "rules_out"] as const) {
484
+ const resolved = resolveInvestigationCase(
485
+ projection,
486
+ {
487
+ evidence: [
488
+ linked(
489
+ ref,
490
+ role,
491
+ "The restart loop is downstream of the auth failure.",
492
+ {
493
+ kind: "Deployment",
494
+ name: "api",
495
+ observation: "issue",
496
+ },
497
+ ),
498
+ ],
499
+ },
500
+ 0,
501
+ );
502
+ const partition = partitionInvestigationEvidence(
503
+ projection.groups,
504
+ undefined,
505
+ resolved,
506
+ );
507
+ const issue = projection.groups.find((group) => group.kind === "issue")!;
508
+ expect(partition.collectionByGroup.get(issue.id)).toBe("main");
509
+ expect(issue.latest.tone).toBe("error");
510
+ expect(issue.latest.tier).toBe("key");
511
+ const html = render(projection, resolved);
512
+ expect(html).toContain("border-l-red-500");
513
+ expect(html).toContain(
514
+ "The restart loop is downstream of the auth failure.",
515
+ );
516
+ }
517
+ });
518
+
519
+ it("orders main by role: cause items in agent order, unlabelled in Radar order, demoted last", () => {
520
+ const issueRef = evidenceRef("a", "c");
521
+ const logsRef = evidenceRef("a", "d");
522
+ const eventsRef = evidenceRef("a", "e");
523
+ const projection = project(
524
+ tool(
525
+ "issues",
526
+ "issues",
527
+ { issues: [criticalIssue], total: 1, total_matched: 1 },
528
+ {
529
+ summary: JSON.stringify({ namespace: "shop" }),
530
+ evidenceRef: issueRef,
531
+ },
532
+ ),
533
+ tool(
534
+ "events",
535
+ "get_events",
536
+ { events: diagnoseBundle.events },
537
+ {
538
+ summary: JSON.stringify({
539
+ namespace: "shop",
540
+ kind: "Pod",
541
+ name: "api-abc",
542
+ }),
543
+ evidenceRef: eventsRef,
544
+ },
545
+ ),
546
+ tool(
547
+ "logs",
548
+ "get_pod_logs",
549
+ {
550
+ lines: ["FATAL: MongoServerError: Authentication failed"],
551
+ totalLines: 1,
552
+ matchedLines: 1,
553
+ fallback: false,
554
+ },
555
+ {
556
+ summary: JSON.stringify({
557
+ namespace: "shop",
558
+ name: "api-abc",
559
+ container: "api",
560
+ previous: true,
561
+ }),
562
+ evidenceRef: logsRef,
563
+ },
564
+ ),
565
+ );
566
+ const kinds = (
567
+ partition: ReturnType<typeof partitionInvestigationEvidence>,
568
+ ) => partition.main.map((group) => group.kind);
569
+ expect(kinds(partitionInvestigationEvidence(projection.groups))).toEqual([
570
+ "issue",
571
+ ]);
572
+ const resolved = resolveInvestigationCase(
573
+ projection,
574
+ {
575
+ evidence: [
576
+ linked(logsRef, "cause", "The password is rejected."),
577
+ linked(eventsRef, "cause", "Kubelet backs off after each failure."),
578
+ linked(
579
+ issueRef,
580
+ "demoted",
581
+ "The crash loop only restates the log line.",
582
+ ),
583
+ ],
584
+ },
585
+ 0,
586
+ );
587
+ expect(
588
+ kinds(
589
+ partitionInvestigationEvidence(projection.groups, undefined, resolved),
590
+ ),
591
+ ).toEqual(["logs", "events", "issue"]);
592
+ // Unlabelled and ruled-out cards sit between symptom and context; a
593
+ // demoted Key card goes last even though Radar would lead with it.
594
+ const framed = resolveInvestigationCase(
595
+ projection,
596
+ {
597
+ evidence: [
598
+ linked(issueRef, "demoted", "Downstream of the auth failure."),
599
+ linked(logsRef, "context", "Checked the previous instance too."),
600
+ linked(eventsRef, "rules_out", "No image pull or scheduling event."),
601
+ ],
602
+ },
603
+ 0,
604
+ );
605
+ expect(
606
+ kinds(
607
+ partitionInvestigationEvidence(projection.groups, undefined, framed),
608
+ ),
609
+ ).toEqual(["events", "logs", "issue"]);
610
+ const html = render(projection, resolved);
611
+ expect(html.indexOf("The password is rejected.")).toBeLessThan(
612
+ html.indexOf("Kubelet backs off"),
613
+ );
614
+ expect(html.indexOf("Kubelet backs off")).toBeLessThan(
615
+ html.indexOf("only restates the log line"),
616
+ );
617
+ expect(html).toContain(">Less relevant<");
618
+ });
619
+
620
+ it("promotes a broader row only when the agent's subject named it", () => {
621
+ const broadRef = evidenceRef("a", "f");
622
+ const projection = project(
623
+ tool(
624
+ "broad",
625
+ "issues",
626
+ {
627
+ issues: [
628
+ { ...criticalIssue, id: "db", name: "db", namespace: "other" },
629
+ ],
630
+ total: 1,
631
+ total_matched: 1,
632
+ },
633
+ {
634
+ summary: JSON.stringify({ namespace: "other" }),
635
+ evidenceRef: broadRef,
636
+ },
637
+ ),
638
+ );
639
+ const subjectless = resolveInvestigationCase(
640
+ projection,
641
+ {
642
+ evidence: [linked(broadRef, "cause", "The one row in this query.")],
643
+ ruledOut: [{ hypothesis: "Dead link otherwise", evidenceIndex: 0 }],
644
+ },
645
+ 0,
646
+ );
647
+ expect(subjectless.items[0].placement).toBe("card");
648
+ expect(
649
+ partitionInvestigationEvidence(projection.groups, undefined, subjectless)
650
+ .collectionByGroup.size,
651
+ ).toBe(0);
652
+ expect(render(projection, subjectless)).not.toContain(
653
+ "Dead link otherwise",
654
+ );
655
+ const named = resolveInvestigationCase(
656
+ projection,
657
+ {
658
+ evidence: [
659
+ linked(broadRef, "cause", "db in other is the upstream failure.", {
660
+ kind: "Deployment",
661
+ namespace: "other",
662
+ name: "db",
663
+ }),
664
+ ],
665
+ },
666
+ 0,
667
+ );
668
+ const partition = partitionInvestigationEvidence(
669
+ projection.groups,
670
+ undefined,
671
+ named,
672
+ );
673
+ expect(partition.main.map((group) => group.kind)).toEqual(["issue"]);
674
+ expect(render(projection, named)).toContain("other/db");
675
+ });
676
+
677
+ it("selects placed groups of any role alongside legacy links", () => {
678
+ const eventsRef = evidenceRef("a", "e");
679
+ const projection = project(
680
+ tool(
681
+ "issues",
682
+ "issues",
683
+ { issues: [criticalIssue], total: 1, total_matched: 1 },
684
+ {
685
+ summary: JSON.stringify({ namespace: "shop" }),
686
+ },
687
+ ),
688
+ tool(
689
+ "events",
690
+ "get_events",
691
+ { events: diagnoseBundle.events },
692
+ {
693
+ summary: JSON.stringify({
694
+ namespace: "shop",
695
+ kind: "Pod",
696
+ name: "api-abc",
697
+ }),
698
+ evidenceRef: eventsRef,
699
+ },
700
+ ),
701
+ );
702
+ const resolved = resolveInvestigationCase(
703
+ projection,
704
+ { evidence: [linked(eventsRef, "context", "Checked, only back-off.")] },
705
+ 0,
706
+ );
707
+ const partition = partitionInvestigationEvidence(
708
+ projection.groups,
709
+ undefined,
710
+ resolved,
711
+ );
712
+ expect(partition.main.map((group) => group.kind)).toEqual([
713
+ "issue",
714
+ "events",
715
+ ]);
716
+ expect(investigationCaseByGroup(resolved).size).toBe(1);
717
+ });
718
+ });
719
+
720
+ describe("diagnose-bundled metrics placement (D-5 with D1)", () => {
721
+ const ref = evidenceRef("a", "m");
722
+ const window = {
723
+ start: "2026-09-06T07:00:00Z",
724
+ end: "2026-09-06T08:00:00Z",
725
+ step: "1m",
726
+ };
727
+ const samples = [
728
+ { timestamp: Date.parse(window.start) / 1000, value: 1 },
729
+ { timestamp: Date.parse(window.end) / 1000, value: 2 },
730
+ ];
731
+ const vital = (category: string) => ({
732
+ category,
733
+ unit: category === "cpu" ? "cores" : "bytes",
734
+ query: `sum(${category}{namespace='shop',pod=~'^(api-abc)$'})`,
735
+ series: [{ labels: {}, dataPoints: samples }],
736
+ });
737
+ const bundleWith = (categories: string[]) =>
738
+ project(
739
+ tool(
740
+ "diag",
741
+ "diagnose",
742
+ {
743
+ ...diagnoseBundle,
744
+ metrics: {
745
+ window,
746
+ pods: 1,
747
+ series: categories.map(vital),
748
+ },
749
+ },
750
+ { evidenceRef: ref },
751
+ ),
752
+ );
753
+
754
+ it("promotes a diagnose-origin chart into main only through a subject that names it", () => {
755
+ const projection = bundleWith(["cpu", "memory"]);
756
+ const metrics = projection.groups.filter(
757
+ (group) => group.kind === "metrics",
758
+ );
759
+ expect(metrics).toHaveLength(2);
760
+ expect(metrics[0].latest.data).toMatchObject({
761
+ type: "metrics",
762
+ origin: "diagnose",
763
+ });
764
+ const uncited = partitionInvestigationEvidence(projection.groups);
765
+ expect(uncited.main.map((group) => group.kind)).not.toContain("metrics");
766
+ expect(
767
+ uncited.workload.filter((group) => group.kind === "metrics"),
768
+ ).toHaveLength(2);
769
+
770
+ const ambiguous = resolveInvestigationCase(
771
+ projection,
772
+ {
773
+ evidence: [
774
+ linked(ref, "symptom", "Memory climbs until the restart.", {
775
+ kind: "Deployment",
776
+ namespace: "shop",
777
+ name: "api",
778
+ observation: "metrics",
779
+ }),
780
+ ],
781
+ },
782
+ 0,
783
+ );
784
+ expect(ambiguous.items[0].placement).toBe("source");
785
+
786
+ const named = resolveInvestigationCase(
787
+ projection,
788
+ {
789
+ evidence: [
790
+ linked(ref, "symptom", "Memory climbs until the restart.", {
791
+ kind: "Deployment",
792
+ group: "apps",
793
+ namespace: "shop",
794
+ name: "api",
795
+ observation: "metrics:memory",
796
+ }),
797
+ ],
798
+ },
799
+ 0,
800
+ );
801
+ expect(named.items[0].placement).toBe("card");
802
+ expect(named.items[0].groupId).toBe(
803
+ metrics.find((group) => group.identity.endsWith(":memory"))!.id,
804
+ );
805
+ const partition = partitionInvestigationEvidence(
806
+ projection.groups,
807
+ undefined,
808
+ named,
809
+ );
810
+ expect(partition.collectionByGroup.get(named.items[0].groupId!)).toBe(
811
+ "main",
812
+ );
813
+ // The chart is now in main and, as the only symptom item, leads the
814
+ // unlabelled Radar cards; the other chart stays where Radar had it.
815
+ expect(partition.main[0].kind).toBe("metrics");
816
+ expect(
817
+ partition.main.filter((group) => group.kind === "metrics"),
818
+ ).toHaveLength(1);
819
+ expect(partition.main.map((group) => group.kind)).toContain("issue");
820
+ const html = render(projection, named);
821
+ expect(html).toContain("Memory working set · deployment shop/api");
822
+ expect(html).toContain("Memory climbs until the restart.");
823
+ });
824
+
825
+ it("needs no category when the bundle captured one chart", () => {
826
+ const projection = bundleWith(["cpu"]);
827
+ const resolved = resolveInvestigationCase(
828
+ projection,
829
+ {
830
+ evidence: [
831
+ linked(ref, "context", "CPU is flat.", {
832
+ kind: "Deployment",
833
+ name: "api",
834
+ observation: "metrics",
835
+ }),
836
+ ],
837
+ },
838
+ 0,
839
+ );
840
+ expect(resolved.items[0].placement).toBe("card");
841
+ expect(
842
+ resolveInvestigationCase(
843
+ projection,
844
+ {
845
+ evidence: [
846
+ linked(ref, "context", "Wrong chart.", {
847
+ kind: "Deployment",
848
+ name: "api",
849
+ observation: "metrics:restarts",
850
+ }),
851
+ ],
852
+ },
853
+ 0,
854
+ ).items[0].placement,
855
+ ).toBe("source");
856
+ });
857
+ });
858
+
859
+ describe("follow-up answers that cite evidence", () => {
860
+ const first = evidenceRef("a", "b");
861
+ const second = evidenceRef("c", "d");
862
+ const window = { start: "2026-09-06T07:00:00Z", end: "2026-09-06T09:00:00Z" };
863
+ const chart = {
864
+ query:
865
+ "sum(container_memory_working_set_bytes{namespace='shop',pod=~'^api-.*',container='api'})",
866
+ type: "range",
867
+ ...window,
868
+ step: "60s",
869
+ series: [
870
+ {
871
+ labels: {},
872
+ dataPoints: [
873
+ { timestamp: Date.parse(window.start) / 1000, value: 1 },
874
+ { timestamp: Date.parse(window.end) / 1000, value: 2 },
875
+ ],
876
+ },
877
+ ],
878
+ selectors: [
879
+ {
880
+ metric: "container_memory_working_set_bytes",
881
+ matchers: [
882
+ { label: "namespace", op: "=", value: "shop" },
883
+ { label: "pod", op: "=~", value: "^api-.*" },
884
+ { label: "container", op: "=", value: "api" },
885
+ ],
886
+ },
887
+ ],
888
+ };
889
+ const projection = projectInvestigationEvidence(
890
+ [
891
+ {
892
+ timeline: [
893
+ tool("diag", "diagnose", diagnoseBundle, { evidenceRef: first }),
894
+ ],
895
+ },
896
+ {
897
+ timeline: [
898
+ tool("prom", "query_prometheus", chart, {
899
+ summary: JSON.stringify({ query: chart.query }),
900
+ evidenceRef: second,
901
+ }),
902
+ ],
903
+ question:
904
+ "chart this workload's memory over the last 2 hours and cite it",
905
+ },
906
+ ],
907
+ target,
908
+ );
909
+
910
+ it("lets the answer turn's placed chart select into main with its chip", () => {
911
+ const chartGroup = projection.groups.find(
912
+ (group) => group.kind === "metrics",
913
+ )!;
914
+ expect(chartGroup.latest.source.turnIndex).toBe(1);
915
+ expect(
916
+ partitionInvestigationEvidence(projection.groups).collectionByGroup.get(
917
+ chartGroup.id,
918
+ ),
919
+ ).toBe("workload");
920
+ const answerCase = resolveInvestigationCase(
921
+ projection,
922
+ {
923
+ evidence: [
924
+ linked(second, "context", "Memory is flat across the window.", {
925
+ kind: "Deployment",
926
+ group: "apps",
927
+ namespace: "shop",
928
+ name: "api",
929
+ container: "api",
930
+ observation: "metrics:memory",
931
+ }),
932
+ ],
933
+ },
934
+ 1,
935
+ );
936
+ expect(answerCase.items[0].placement).toBe("card");
937
+ expect(answerCase.items[0].groupId).toBe(chartGroup.id);
938
+ // The chart states no resource (its selectors only pattern-match the
939
+ // pods), so a subject without an evidence kind cannot name it.
940
+ expect(
941
+ resolveInvestigationCase(
942
+ projection,
943
+ {
944
+ evidence: [
945
+ linked(second, "context", "No kind given.", {
946
+ kind: "Deployment",
947
+ name: "api",
948
+ }),
949
+ ],
950
+ },
951
+ 1,
952
+ ).items[0].placement,
953
+ ).toBe("source");
954
+ const partition = partitionInvestigationEvidence(
955
+ projection.groups,
956
+ undefined,
957
+ answerCase,
958
+ );
959
+ expect(partition.collectionByGroup.get(chartGroup.id)).toBe("main");
960
+ const html = render(projection, answerCase);
961
+ expect(html).toContain("Memory is flat across the window.");
962
+ expect(html).toContain(">Context<");
963
+ });
964
+
965
+ it("keeps a malformed subject at its source instead of treating it as omitted", () => {
966
+ // Omitting a subject asks Radar to place the note wherever the cited call
967
+ // produced; supplying a broken one asked for something specific. Falling
968
+ // back to the omitted behaviour put the note on whichever observation
969
+ // happened to be the only one for that source.
970
+ const omitted = resolveInvestigationCase(
971
+ projection,
972
+ { evidence: [linked(second, "context", "Flat.")] },
973
+ 1,
974
+ );
975
+ expect(omitted.items[0].placement).toBe("card");
976
+
977
+ const malformed = resolveInvestigationCase(
978
+ projection,
979
+ {
980
+ evidence: [
981
+ linked(second, "context", "Flat.", {
982
+ name: "api",
983
+ } as unknown as Parameters<typeof linked>[3]),
984
+ ],
985
+ },
986
+ 1,
987
+ );
988
+ expect(malformed.items[0].placement).toBe("source");
989
+ expect(malformed.items[0].groupId).toBeUndefined();
990
+ });
991
+
992
+ it("places a log claim that names the diagnosed workload and its container", () => {
993
+ const assessmentCase = resolveInvestigationCase(
994
+ projection,
995
+ {
996
+ evidence: [
997
+ linked(first, "demoted", "The ERROR lines are routes elsewhere.", {
998
+ kind: "Deployment",
999
+ group: "apps",
1000
+ namespace: "shop",
1001
+ name: "api",
1002
+ container: "api",
1003
+ stream: "current",
1004
+ observation: "logs",
1005
+ }),
1006
+ ],
1007
+ },
1008
+ 0,
1009
+ );
1010
+ expect(assessmentCase.items[0].placement).toBe("card");
1011
+ expect(
1012
+ projection.groups.find(
1013
+ (group) => group.id === assessmentCase.items[0].groupId,
1014
+ )?.identity,
1015
+ ).toBe("logs:current:api-abc:api");
1016
+ });
1017
+
1018
+ it("opens the card's note block for a role even with no sentence", () => {
1019
+ // The note itself handles a claimless role, but the block around it was
1020
+ // gated on a claim, so the framing vanished from the card entirely.
1021
+ const claimless = resolveInvestigationCase(
1022
+ projection,
1023
+ {
1024
+ evidence: [
1025
+ linked(first, "demoted", "", {
1026
+ kind: "Deployment",
1027
+ group: "apps",
1028
+ namespace: "shop",
1029
+ name: "api",
1030
+ container: "api",
1031
+ stream: "current",
1032
+ observation: "logs",
1033
+ }),
1034
+ ],
1035
+ },
1036
+ 0,
1037
+ );
1038
+ expect(claimless.items[0].placement).toBe("card");
1039
+ expect(claimless.items[0].claim).toBe("");
1040
+ expect(render(projection, claimless)).toContain("Less relevant");
1041
+ });
1042
+
1043
+ it("lists an answer turn's sources under the answer", () => {
1044
+ const answerCase = resolveInvestigationCase(
1045
+ projection,
1046
+ { evidence: [linked(second, "context", "Flat.")] },
1047
+ 1,
1048
+ );
1049
+ const html = renderToStaticMarkup(
1050
+ <ResultCard
1051
+ diagnosis={{
1052
+ rootCause: "",
1053
+ report: "Memory is flat.",
1054
+ remediation: [],
1055
+ }}
1056
+ followup
1057
+ assessmentSources={
1058
+ <AssessmentSources
1059
+ investigationCase={answerCase}
1060
+ onViewSource={onViewSource}
1061
+ />
1062
+ }
1063
+ />,
1064
+ );
1065
+ expect(html).toContain("Assessment details");
1066
+ expect(html).toContain("Sources used for this assessment");
1067
+ expect(html).toContain("1 agent note on an evidence card");
1068
+ expect(html).not.toContain(">Context<");
1069
+ });
1070
+ });
1071
+
1072
+ describe("carrying earlier assessments' card notes", () => {
1073
+ const item = (
1074
+ index: number,
1075
+ groupId: string | undefined,
1076
+ placement: "card" | "revision" | "source",
1077
+ role: "cause" | "context" | "demoted" = "context",
1078
+ ) =>
1079
+ ({
1080
+ index,
1081
+ role,
1082
+ claim: `claim ${index}`,
1083
+ source: { id: `s${index}` },
1084
+ placement,
1085
+ groupId,
1086
+ }) as unknown as InvestigationCaseItem;
1087
+
1088
+ it("keeps an earlier turn's note on a card the live turn did not address", () => {
1089
+ const live = {
1090
+ items: [item(0, "chart", "card")],
1091
+ ruledOut: [],
1092
+ } as unknown as InvestigationCaseResolution;
1093
+ const first = {
1094
+ items: [
1095
+ item(0, "logs", "card", "demoted"),
1096
+ item(1, "chart", "card", "cause"),
1097
+ item(2, undefined, "source"),
1098
+ item(3, "deploy", "revision"),
1099
+ ],
1100
+ ruledOut: [{ hypothesis: "x" }],
1101
+ } as unknown as InvestigationCaseResolution;
1102
+ const merged = mergeInvestigationCases(live, [first]);
1103
+ expect(merged?.items.map((entry) => [entry.groupId, entry.role])).toEqual([
1104
+ ["chart", "context"],
1105
+ ["logs", "demoted"],
1106
+ ["deploy", "context"],
1107
+ ]);
1108
+ expect(merged?.ruledOut).toEqual([]);
1109
+ });
1110
+
1111
+ it("lets the newer earlier turn win a card and leaves a lone live case untouched", () => {
1112
+ const live = {
1113
+ items: [],
1114
+ ruledOut: [],
1115
+ } as unknown as InvestigationCaseResolution;
1116
+ const newer = {
1117
+ items: [item(0, "logs", "card", "context")],
1118
+ ruledOut: [],
1119
+ } as unknown as InvestigationCaseResolution;
1120
+ const older = {
1121
+ items: [item(0, "logs", "card", "demoted"), item(1, "pod", "card")],
1122
+ ruledOut: [],
1123
+ } as unknown as InvestigationCaseResolution;
1124
+ const merged = mergeInvestigationCases(live, [newer, older]);
1125
+ expect(merged?.items.map((entry) => [entry.groupId, entry.role])).toEqual([
1126
+ ["logs", "context"],
1127
+ ["pod", "context"],
1128
+ ]);
1129
+ expect(mergeInvestigationCases(live, [])).toBe(live);
1130
+ expect(mergeInvestigationCases(undefined, [])).toBeUndefined();
1131
+ });
1132
+
1133
+ it("recognises its own surviving notes across a re-resolved merge", () => {
1134
+ // The pane feeds the merge freshly resolved copies of every turn, so the
1135
+ // assessment's own items arrive equal but not identical. An identity test
1136
+ // reported them as gone, which silently retired the conflict banner's
1137
+ // reframing whenever any unrelated follow-up arrived.
1138
+ const assessmentItems = [
1139
+ item(0, "logs", "card", "demoted"),
1140
+ item(1, "crash", "card", "demoted"),
1141
+ ];
1142
+ const reResolved = assessmentItems.map(
1143
+ (entry) => ({ ...entry }) as InvestigationCaseItem,
1144
+ );
1145
+ expect(reResolved[0]).not.toBe(assessmentItems[0]);
1146
+ expect(
1147
+ investigationCaseItemsStillRendered(assessmentItems, reResolved).map(
1148
+ (entry) => entry.groupId,
1149
+ ),
1150
+ ).toEqual(["logs", "crash"]);
1151
+ // A note the merge dropped is correctly reported as gone.
1152
+ expect(
1153
+ investigationCaseItemsStillRendered(assessmentItems, [reResolved[0]]).map(
1154
+ (entry) => entry.groupId,
1155
+ ),
1156
+ ).toEqual(["logs"]);
1157
+ expect(investigationCaseItemsStillRendered(assessmentItems, [])).toEqual(
1158
+ [],
1159
+ );
1160
+ });
1161
+
1162
+ it("carries every note the winning assessment left on a card, not just the first", () => {
1163
+ // A card note and a note pinned to a superseded read are two readings of
1164
+ // one group by the same assessment. Taking coverage per item dropped the
1165
+ // second, so a visible Cause note could vanish behind a revision note.
1166
+ const live = {
1167
+ items: [item(0, "other", "card", "context")],
1168
+ ruledOut: [],
1169
+ } as unknown as InvestigationCaseResolution;
1170
+ const winner = {
1171
+ items: [
1172
+ item(1, "logs", "revision", "context"),
1173
+ item(2, "logs", "card", "cause"),
1174
+ ],
1175
+ ruledOut: [],
1176
+ } as unknown as InvestigationCaseResolution;
1177
+ const older = {
1178
+ items: [item(3, "logs", "card", "demoted")],
1179
+ ruledOut: [],
1180
+ } as unknown as InvestigationCaseResolution;
1181
+ const merged = mergeInvestigationCases(live, [winner, older]);
1182
+ expect(
1183
+ merged?.items.map((entry) => [
1184
+ entry.groupId,
1185
+ entry.placement,
1186
+ entry.role,
1187
+ ]),
1188
+ ).toEqual([
1189
+ ["other", "card", "context"],
1190
+ ["logs", "revision", "context"],
1191
+ ["logs", "card", "cause"],
1192
+ ]);
1193
+ });
1194
+ });
1195
+
1196
+ describe("agent case robustness", () => {
1197
+ const ref = evidenceRef("a", "b");
1198
+
1199
+ it("renders a healthy assessment's context items and sources", () => {
1200
+ const projection = project(
1201
+ tool(
1202
+ "read",
1203
+ "get_resource",
1204
+ { ...deployment, status: { readyReplicas: 1, replicas: 1 } },
1205
+ {
1206
+ evidenceRef: ref,
1207
+ },
1208
+ ),
1209
+ );
1210
+ const diagnosis = {
1211
+ healthy: true,
1212
+ rootCause: "",
1213
+ report: "Looks fine.",
1214
+ remediation: [],
1215
+ evidence: [linked(ref, "context", "One replica, ready, no restarts.")],
1216
+ } satisfies Diagnosis;
1217
+ const resolved = resolveInvestigationCase(projection, diagnosis, 0);
1218
+ expect(resolved.items[0].placement).toBe("card");
1219
+ const partition = partitionInvestigationEvidence(
1220
+ projection.groups,
1221
+ undefined,
1222
+ resolved,
1223
+ );
1224
+ expect(partition.main.map((group) => group.kind)).toEqual(["resource"]);
1225
+ const html = render(projection, resolved);
1226
+ expect(html).toContain("One replica, ready, no restarts.");
1227
+ expect(html).toContain(">Context<");
1228
+ const sources = renderToStaticMarkup(
1229
+ <AssessmentSources
1230
+ investigationCase={resolved}
1231
+ onViewSource={onViewSource}
1232
+ />,
1233
+ );
1234
+ expect(sources).toContain("Sources used for this assessment");
1235
+ expect(sources).toContain("1 agent note on an evidence card");
1236
+ expect(sources).not.toContain(">Context<");
1237
+ expect(sources).not.toContain("One replica, ready, no restarts.");
1238
+ });
1239
+
1240
+ it("renders legacy refs without a case exactly as before", () => {
1241
+ const projection = project(
1242
+ tool("diag", "diagnose", diagnoseBundle, { evidenceRef: ref }),
1243
+ );
1244
+ const resolution = resolveInvestigationRootCauseEvidence(
1245
+ projection,
1246
+ { status: "linked", refs: [ref] },
1247
+ 0,
1248
+ );
1249
+ const legacyDiagnosis: Diagnosis = {
1250
+ rootCause: "auth",
1251
+ report: "",
1252
+ remediation: [],
1253
+ rootCauseEvidence: { status: "linked", refs: [ref] },
1254
+ };
1255
+ const resolved = resolveInvestigationCase(projection, legacyDiagnosis, 0);
1256
+ expect(resolved).toEqual({ items: [], ruledOut: [] });
1257
+ expect(render(projection, undefined, resolution)).toBe(
1258
+ render(projection, resolved, resolution),
1259
+ );
1260
+ expect(
1261
+ partitionInvestigationEvidence(projection.groups, resolution).main.map(
1262
+ (group) => group.id,
1263
+ ),
1264
+ ).toEqual(
1265
+ partitionInvestigationEvidence(
1266
+ projection.groups,
1267
+ resolution,
1268
+ resolved,
1269
+ ).main.map((group) => group.id),
1270
+ );
1271
+ const legacySources = renderToStaticMarkup(
1272
+ <AssessmentSources resolution={resolution} onViewSource={onViewSource} />,
1273
+ );
1274
+ expect(legacySources).toContain(
1275
+ '<div class="font-medium text-theme-text-primary">Diagnose</div>',
1276
+ );
1277
+ expect(legacySources).not.toContain("agent note");
1278
+ expect(legacySources).not.toContain("data-source-placed-claims");
1279
+ expect(
1280
+ renderToStaticMarkup(
1281
+ <AssessmentSources
1282
+ resolution={resolution}
1283
+ onViewSource={onViewSource}
1284
+ />,
1285
+ ),
1286
+ ).toBe(
1287
+ renderToStaticMarkup(
1288
+ <AssessmentSources
1289
+ resolution={resolution}
1290
+ investigationCase={resolved}
1291
+ onViewSource={onViewSource}
1292
+ />,
1293
+ ),
1294
+ );
1295
+ });
1296
+
1297
+ it("keeps valid items when siblings are unlinked, malformed, foreign, or from another turn", () => {
1298
+ const stale = evidenceRef("a", "z");
1299
+ const projection = projectInvestigationEvidence(
1300
+ [
1301
+ {
1302
+ timeline: [
1303
+ tool("old", "get_resource", deployment, { evidenceRef: stale }),
1304
+ ],
1305
+ },
1306
+ {
1307
+ timeline: [
1308
+ tool("diag", "diagnose", diagnoseBundle, { evidenceRef: ref }),
1309
+ ],
1310
+ },
1311
+ ],
1312
+ target,
1313
+ );
1314
+ const resolved = resolveInvestigationCase(
1315
+ projection,
1316
+ {
1317
+ evidence: [
1318
+ { status: "unlinked" },
1319
+ linked(ref, "cause", "Valid, on the issue.", {
1320
+ kind: "Deployment",
1321
+ name: "api",
1322
+ observation: "issue",
1323
+ }),
1324
+ linked("not-a-ref", "cause", "Bad ref."),
1325
+ {
1326
+ status: "linked",
1327
+ ref,
1328
+ role: "verdict" as never,
1329
+ claim: "Bad role.",
1330
+ },
1331
+ linked(stale, "cause", "Previous turn."),
1332
+ linked(ref, "context", "Bad subject drops to source.", {
1333
+ kind: "Pod",
1334
+ name: "api-abc",
1335
+ stream: "older" as never,
1336
+ }),
1337
+ ],
1338
+ ruledOut: [
1339
+ { hypothesis: "Points at the unlinked slot", evidenceIndex: 0 },
1340
+ { hypothesis: "Points past the end", evidenceIndex: 9 },
1341
+ { hypothesis: "Points at a source-placed item", evidenceIndex: 5 },
1342
+ { hypothesis: "Kept", evidenceIndex: 1 },
1343
+ ],
1344
+ },
1345
+ 1,
1346
+ );
1347
+ expect(resolved.items.map((item) => [item.index, item.placement])).toEqual([
1348
+ [1, "card"],
1349
+ [5, "source"],
1350
+ ]);
1351
+ expect(resolved.ruledOut.map((entry) => entry.hypothesis)).toEqual([
1352
+ "Kept",
1353
+ ]);
1354
+ const duplicated = resolveInvestigationCase(
1355
+ projection,
1356
+ {
1357
+ evidence: [
1358
+ linked(ref, "rules_out", "Once.", {
1359
+ kind: "Deployment",
1360
+ name: "api",
1361
+ observation: "issue",
1362
+ }),
1363
+ ],
1364
+ ruledOut: [
1365
+ { hypothesis: "Twice", evidenceIndex: 0 },
1366
+ { hypothesis: "Twice", evidenceIndex: 0 },
1367
+ ],
1368
+ },
1369
+ 1,
1370
+ );
1371
+ expect(duplicated.ruledOut).toHaveLength(1);
1372
+ const html = render(projection, resolved);
1373
+ expect(html).toContain('data-testid="investigation-ruled-out"');
1374
+ expect(html).toContain("Kept");
1375
+ expect(html).not.toContain("Points at a source-placed item");
1376
+ expect(html).toContain("See CrashLoopBackOff");
1377
+ });
1378
+
1379
+ it("shows a source-placed claim in Assessment details with its role", () => {
1380
+ const projection = project(
1381
+ tool("diag", "diagnose", diagnoseBundle, { evidenceRef: ref }),
1382
+ );
1383
+ const resolved = resolveInvestigationCase(
1384
+ projection,
1385
+ {
1386
+ evidence: [
1387
+ linked(ref, "rules_out", "Nothing in this bundle points at DNS."),
1388
+ ],
1389
+ },
1390
+ 0,
1391
+ );
1392
+ const rows = assessmentSourceRows(undefined, resolved);
1393
+ expect(rows).toHaveLength(1);
1394
+ expect(rows[0].items[0].placement).toBe("source");
1395
+ const html = renderToStaticMarkup(
1396
+ <AssessmentSources
1397
+ investigationCase={resolved}
1398
+ onViewSource={onViewSource}
1399
+ />,
1400
+ );
1401
+ expect(html).toContain("Nothing in this bundle points at DNS.");
1402
+ expect(html).toContain(">Rules out<");
1403
+ expect(html).toContain("data-source-placed-claims");
1404
+ expect(render(projection, resolved)).not.toContain("points at DNS");
1405
+ });
1406
+
1407
+ it("lists an earlier assessment's placed claims read-only with their observation", () => {
1408
+ const projection = project(
1409
+ tool("diag", "diagnose", diagnoseBundle, { evidenceRef: ref }),
1410
+ );
1411
+ const resolved = resolveInvestigationCase(
1412
+ projection,
1413
+ {
1414
+ evidence: [
1415
+ linked(ref, "cause", "Was the cause back then.", {
1416
+ kind: "Deployment",
1417
+ name: "api",
1418
+ observation: "issue",
1419
+ }),
1420
+ ],
1421
+ },
1422
+ 0,
1423
+ );
1424
+ const current = renderToStaticMarkup(
1425
+ <AssessmentSources
1426
+ investigationCase={resolved}
1427
+ onViewSource={onViewSource}
1428
+ />,
1429
+ );
1430
+ expect(current).not.toContain("Was the cause back then.");
1431
+ const earlier = renderToStaticMarkup(
1432
+ <AssessmentSources
1433
+ investigationCase={resolved}
1434
+ readOnly
1435
+ onViewSource={onViewSource}
1436
+ />,
1437
+ );
1438
+ expect(earlier).toContain("Notes from this assessment");
1439
+ const withCode = renderToStaticMarkup(
1440
+ <AgentClaimNote claim="The `REDIS_PASSWORD` key is unset; `envFrom` still mounts it." />,
1441
+ );
1442
+ expect(withCode).toContain('<code class="inline-code');
1443
+ expect(withCode).toContain(">REDIS_PASSWORD</code>");
1444
+ expect(withCode).toContain(">envFrom</code>");
1445
+ expect(withCode).not.toContain("`");
1446
+ expect(
1447
+ renderToStaticMarkup(<AgentClaimNote claim="Unbalanced `tick here." />),
1448
+ ).toContain("Unbalanced `tick here.");
1449
+ expect(earlier).toContain(
1450
+ "CrashLoopBackOff · </span>Was the cause back then.",
1451
+ );
1452
+ expect(earlier).toContain(">Cause<");
1453
+ expect(earlier).toContain("Agent:");
1454
+ });
1455
+
1456
+ it("lists an unpinnable ruled-out hypothesis nowhere", () => {
1457
+ const projection = project(
1458
+ tool("diag", "diagnose", diagnoseBundle, { evidenceRef: ref }),
1459
+ );
1460
+ const resolved = resolveInvestigationCase(
1461
+ projection,
1462
+ {
1463
+ evidence: [linked(ref, "rules_out", "Bundle-wide claim.")],
1464
+ ruledOut: [{ hypothesis: "DNS is broken", evidenceIndex: 0 }],
1465
+ },
1466
+ 0,
1467
+ );
1468
+ expect(resolved.ruledOut).toEqual([]);
1469
+ expect(render(projection, resolved)).not.toContain("DNS is broken");
1470
+ });
1471
+ });
1472
+
1473
+ describe("the agent's contribution is one attributed row", () => {
1474
+ it("keeps the role inside the note rather than beside the resource title", () => {
1475
+ const html = renderToStaticMarkup(
1476
+ <AgentClaimNote role="cause" claim="The sealed value is stale." />,
1477
+ );
1478
+ // Sparkle, role and sentence share one row, so the role is as plainly the
1479
+ // agent's as the sentence is. Beside the title it sat in the same slot as
1480
+ // Radar's own badges.
1481
+ const row = html.match(/<p[^>]*data-agent-claim[\s\S]*<\/p>/)?.[0] ?? "";
1482
+ expect(row).toContain("Cause");
1483
+ // The chip carries the attribution visually; the word is the fallback for
1484
+ // when colour and iconography do not reach the reader.
1485
+ expect(row).toContain("Agent:");
1486
+ expect(row).toContain("The sealed value is stale.");
1487
+ });
1488
+
1489
+ it("names the hypothesis a rules-out card excludes, beside the chip", () => {
1490
+ const html = renderToStaticMarkup(
1491
+ <AgentClaimNote
1492
+ role="rules_out"
1493
+ excludes="The Atlas user was deleted"
1494
+ claim="Same user authenticates fine here."
1495
+ />,
1496
+ );
1497
+ // The chip stays short; a hypothesis long enough to be useful would
1498
+ // otherwise make a badge that swallows the row.
1499
+ expect(html).toContain("Rules out");
1500
+ expect(html).toContain("The Atlas user was deleted");
1501
+ expect(html).not.toContain("Rules out: The Atlas user was deleted");
1502
+ // Without the hypothesis the reader had to find it in a separate block.
1503
+ const bare = renderToStaticMarkup(
1504
+ <AgentClaimNote role="rules_out" claim="Same user authenticates fine." />,
1505
+ );
1506
+ expect(bare).toContain("Rules out");
1507
+ expect(bare).not.toContain("The Atlas user was deleted");
1508
+ });
1509
+
1510
+ it("never states a rejected hypothesis on a card that is not ruling it out", () => {
1511
+ // The agent can point a ruled-out hypothesis at a card it also called a
1512
+ // symptom. "The container is being OOMKilled" printed beside a Symptom
1513
+ // chip reads as a fact rather than as something disproved.
1514
+ const html = renderToStaticMarkup(
1515
+ <AgentClaimNote
1516
+ role="symptom"
1517
+ excludes="The container is being OOMKilled"
1518
+ claim="Memory peaked at 277MB against a 2Gi limit."
1519
+ />,
1520
+ );
1521
+ expect(html).toContain("Symptom");
1522
+ expect(html).not.toContain("OOMKilled");
1523
+ });
1524
+
1525
+ it("says how many were lost rather than repairing them", () => {
1526
+ const html = renderToStaticMarkup(
1527
+ <AssessmentSources unlinkedEvidence={2} onViewSource={onViewSource} />,
1528
+ );
1529
+ expect(html).toContain(
1530
+ "2 agent notes could not be linked to Radar results and are not shown.",
1531
+ );
1532
+ const one = renderToStaticMarkup(
1533
+ <AssessmentSources unlinkedEvidence={1} onViewSource={onViewSource} />,
1534
+ );
1535
+ expect(one).toContain("1 agent note could not be linked");
1536
+ // Nothing lost, nothing said.
1537
+ expect(
1538
+ renderToStaticMarkup(
1539
+ <AssessmentSources unlinkedEvidence={0} onViewSource={onViewSource} />,
1540
+ ),
1541
+ ).toBe("");
1542
+ });
1543
+
1544
+ it("says the notes were unreadable when they were not a list at all", () => {
1545
+ // There is no count to give here, so the pane states the fact instead of
1546
+ // inventing a number.
1547
+ const html = renderToStaticMarkup(
1548
+ <AssessmentSources evidenceMalformed onViewSource={onViewSource} />,
1549
+ );
1550
+ expect(html).toContain("could not be read, so none are shown");
1551
+ expect(html).not.toContain("could not be linked");
1552
+ });
1553
+
1554
+ it("labels the role that says an adverse result is not a live problem", () => {
1555
+ const html = renderToStaticMarkup(<AgentRoleChip role="benign" />);
1556
+ expect(html).toContain("Not a problem");
1557
+ });
1558
+
1559
+ it("still shows an attributed role when the agent left no sentence", () => {
1560
+ const html = renderToStaticMarkup(
1561
+ <AgentClaimNote role="context" claim="" />,
1562
+ );
1563
+ expect(html).toContain("Context");
1564
+ expect(html).not.toContain("note:");
1565
+ // Nothing at all to say and no role: render nothing.
1566
+ expect(renderToStaticMarkup(<AgentClaimNote claim="" />)).toBe("");
1567
+ });
1568
+ });