@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.13.1",
3
+ "version": "1.13.2",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -41,7 +41,7 @@
41
41
  "yaml": "^2.9.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@skyhook-io/k8s-ui": ">=1.13.4",
44
+ "@skyhook-io/k8s-ui": ">=1.14.2",
45
45
  "@tanstack/react-query": ">=5",
46
46
  "@xyflow/react": ">=12.0.0",
47
47
  "clsx": ">=2",
package/src/App.tsx CHANGED
@@ -2464,7 +2464,25 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2464
2464
  below the header and right of the nav rail, sharing the frame with the
2465
2465
  drawers above. Docked = right slot (pushes content via contentGutter);
2466
2466
  maximized = fills the frame. */}
2467
- {diagnoseOpen && <DiagnoseSurface topInset={chromeless ? 0 : APP_HEADER_HEIGHT} />}
2467
+ {diagnoseOpen && (
2468
+ <DiagnoseSurface
2469
+ topInset={chromeless ? 0 : APP_HEADER_HEIGHT}
2470
+ onOpenResource={(ref) => {
2471
+ const resource: SelectedResource = {
2472
+ kind: ref.kind,
2473
+ namespace: ref.namespace ?? '',
2474
+ name: ref.name,
2475
+ group: ref.group,
2476
+ }
2477
+ const path = relatedResourcePath(resource)
2478
+ if (path.startsWith('/workload/')) {
2479
+ navigate(path)
2480
+ return
2481
+ }
2482
+ navigateFromIssue(resource)
2483
+ }}
2484
+ />
2485
+ )}
2468
2486
 
2469
2487
  {/* Port Forward floating panel (indicator lives in header) */}
2470
2488
  <PortForwardPanel />
package/src/RadarApp.tsx CHANGED
@@ -106,10 +106,10 @@ export interface RadarAppProps {
106
106
  */
107
107
  documentTitleSuffix?: string;
108
108
  /**
109
- * Injects a resource-level "Diagnose" action (e.g. a "Diagnose with AI"
109
+ * Injects a resource-level "Investigate" action (e.g. an "Investigate with AI"
110
110
  * button) into every resource detail action bar's right-aligned universal
111
111
  * actions. The host returns the node to render given the resource context.
112
- * Standalone Radar omits this and renders no Diagnose button — OSS stays
112
+ * Standalone Radar omits this and renders no Investigate button — OSS stays
113
113
  * agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
114
114
  */
115
115
  renderDiagnoseAction?: RenderDiagnoseAction;
@@ -0,0 +1,268 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createRun, subscribeRun, type DiagnoseStreamEvent } from "./diagnose";
3
+
4
+ type SSEListener = (event: { data: string; lastEventId: string }) => void;
5
+
6
+ class FakeEventSource {
7
+ static CLOSED = 2;
8
+ static instances: FakeEventSource[] = [];
9
+
10
+ readyState = 1;
11
+ onopen: (() => void) | null = null;
12
+ onerror: (() => void) | null = null;
13
+ closed = false;
14
+ private listeners = new Map<string, SSEListener>();
15
+
16
+ constructor(
17
+ readonly url: string,
18
+ readonly options?: EventSourceInit,
19
+ ) {
20
+ FakeEventSource.instances.push(this);
21
+ }
22
+
23
+ addEventListener(type: string, listener: SSEListener) {
24
+ this.listeners.set(type, listener);
25
+ }
26
+
27
+ emit(type: string, event: DiagnoseStreamEvent, lastEventId = "") {
28
+ this.listeners.get(type)?.({ data: JSON.stringify(event), lastEventId });
29
+ }
30
+
31
+ close() {
32
+ this.closed = true;
33
+ }
34
+ }
35
+
36
+ describe("createRun", () => {
37
+ afterEach(() => vi.unstubAllGlobals());
38
+
39
+ it.each(["argoproj.io", ""])(
40
+ "sends the target API group %j in the request body",
41
+ async (group) => {
42
+ const fetchMock = vi.fn().mockResolvedValue({
43
+ ok: true,
44
+ json: async () => ({
45
+ id: "run-1",
46
+ kind: "Rollout",
47
+ group,
48
+ namespace: "prod",
49
+ name: "checkout",
50
+ }),
51
+ });
52
+ vi.stubGlobal("fetch", fetchMock);
53
+
54
+ await createRun(
55
+ {
56
+ kind: "Rollout",
57
+ group,
58
+ namespace: "prod",
59
+ name: "checkout",
60
+ },
61
+ { agent: "codex" },
62
+ );
63
+
64
+ const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
65
+ expect(JSON.parse(String(init.body))).toEqual({
66
+ kind: "Rollout",
67
+ group,
68
+ namespace: "prod",
69
+ name: "checkout",
70
+ agent: "codex",
71
+ });
72
+ },
73
+ );
74
+ });
75
+
76
+ it("passes durable SSE sequence IDs and explanation origin through replay", () => {
77
+ vi.stubGlobal("EventSource", FakeEventSource);
78
+ const onEvent = vi.fn();
79
+ const close = subscribeRun("sequence-test", { onEvent });
80
+ const source = FakeEventSource.instances.at(-1)!;
81
+ source.emit("done", { type: "done" }, "42");
82
+ source.emit("turn", { type: "turn", explainAssessment: 42 }, "43");
83
+ expect(onEvent).toHaveBeenNthCalledWith(1, { type: "done" }, 42);
84
+ expect(onEvent).toHaveBeenNthCalledWith(
85
+ 2,
86
+ { type: "turn", explainAssessment: 42 },
87
+ 43,
88
+ );
89
+ close();
90
+ vi.unstubAllGlobals();
91
+ });
92
+
93
+ describe("subscribeRun replay boundaries", () => {
94
+ beforeEach(() => {
95
+ FakeEventSource.instances = [];
96
+ vi.stubGlobal("EventSource", FakeEventSource);
97
+ });
98
+
99
+ afterEach(() => vi.unstubAllGlobals());
100
+
101
+ it("brackets every connection replay and forwards the completion marker", () => {
102
+ const starts = vi.fn();
103
+ const events: DiagnoseStreamEvent[] = [];
104
+ const cancel = subscribeRun("run-1", {
105
+ onReplayStart: starts,
106
+ onEvent: (event) => events.push(event),
107
+ });
108
+ const source = FakeEventSource.instances[0]!;
109
+
110
+ source.onopen?.();
111
+ source.emit("turn", { type: "turn" });
112
+ source.emit("replay_complete", { type: "replay_complete" });
113
+ source.onopen?.(); // EventSource reconnects through the same instance.
114
+
115
+ expect(starts).toHaveBeenCalledTimes(2);
116
+ expect(events.map((event) => event.type)).toEqual([
117
+ "turn",
118
+ "replay_complete",
119
+ ]);
120
+ cancel();
121
+ expect(source.closed).toBe(true);
122
+ });
123
+
124
+ it("distinguishes a durable closed sentinel from an unavailable stream", () => {
125
+ const durableClose = vi.fn();
126
+ subscribeRun("run-1", {
127
+ onEvent: vi.fn(),
128
+ onClosed: durableClose,
129
+ });
130
+ const closedSource = FakeEventSource.instances[0]!;
131
+ closedSource.emit("closed", { type: "closed" });
132
+ closedSource.readyState = FakeEventSource.CLOSED;
133
+ closedSource.onerror?.();
134
+ expect(durableClose).toHaveBeenCalledWith("run_closed");
135
+ expect(durableClose).toHaveBeenCalledTimes(1);
136
+
137
+ const unavailable = vi.fn();
138
+ subscribeRun("run-2", {
139
+ onEvent: vi.fn(),
140
+ onClosed: unavailable,
141
+ });
142
+ const source = FakeEventSource.instances[1]!;
143
+ source.readyState = FakeEventSource.CLOSED;
144
+ source.onerror?.();
145
+ expect(unavailable).toHaveBeenCalledWith("unavailable");
146
+ });
147
+
148
+ it("forwards hydration failures while preserving only retryable reconnects", () => {
149
+ const retryEvents: DiagnoseStreamEvent[] = [];
150
+ subscribeRun("run-retry", {
151
+ onEvent: (event) => retryEvents.push(event),
152
+ });
153
+ const retrySource = FakeEventSource.instances[0]!;
154
+ retrySource.emit("history_unavailable", {
155
+ type: "history_unavailable",
156
+ error: "history store is busy",
157
+ retryable: true,
158
+ });
159
+ expect(retryEvents).toEqual([
160
+ {
161
+ type: "history_unavailable",
162
+ error: "history store is busy",
163
+ retryable: true,
164
+ },
165
+ ]);
166
+ expect(retrySource.closed).toBe(false);
167
+
168
+ const permanentEvents: DiagnoseStreamEvent[] = [];
169
+ subscribeRun("run-permanent", {
170
+ onEvent: (event) => permanentEvents.push(event),
171
+ });
172
+ const permanentSource = FakeEventSource.instances[1]!;
173
+ permanentSource.emit("history_unavailable", {
174
+ type: "history_unavailable",
175
+ error: "history cannot be decoded",
176
+ retryable: false,
177
+ });
178
+ expect(permanentEvents.map((event) => event.type)).toEqual([
179
+ "history_unavailable",
180
+ ]);
181
+ expect(permanentSource.closed).toBe(true);
182
+ });
183
+
184
+ it("ignores a trailing closed frame after a permanent history failure", () => {
185
+ const events: DiagnoseStreamEvent[] = [];
186
+ const onClosed = vi.fn();
187
+ subscribeRun("run-corrupt-history", {
188
+ onEvent: (event) => events.push(event),
189
+ onClosed,
190
+ });
191
+ const source = FakeEventSource.instances[0]!;
192
+
193
+ source.emit("history_unavailable", {
194
+ type: "history_unavailable",
195
+ error: "history cannot be decoded",
196
+ retryable: false,
197
+ });
198
+ // This frame can already be queued when history_unavailable closes the
199
+ // EventSource. It must not relabel the run as evicted in InvestigationView.
200
+ source.emit("closed", { type: "closed" });
201
+
202
+ expect(events).toEqual([
203
+ {
204
+ type: "history_unavailable",
205
+ error: "history cannot be decoded",
206
+ retryable: false,
207
+ },
208
+ ]);
209
+ expect(onClosed).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it("forwards evidence-backed apply outcomes on terminal events", () => {
213
+ const events: DiagnoseStreamEvent[] = [];
214
+ subscribeRun("run-apply", {
215
+ onEvent: (event) => events.push(event),
216
+ });
217
+ const source = FakeEventSource.instances[0]!;
218
+ source.emit("done", {
219
+ type: "done",
220
+ applyOutcome: "confirmed",
221
+ diagnosis: {
222
+ rootCause: "",
223
+ report: "Deployment updated.",
224
+ remediation: [],
225
+ },
226
+ });
227
+ source.emit("error", {
228
+ type: "error",
229
+ applyOutcome: "unknown",
230
+ error: "The write result was incomplete.",
231
+ });
232
+
233
+ expect(events).toEqual([
234
+ expect.objectContaining({ type: "done", applyOutcome: "confirmed" }),
235
+ expect.objectContaining({ type: "error", applyOutcome: "unknown" }),
236
+ ]);
237
+ });
238
+ });
239
+
240
+ describe("investigation start requests", () => {
241
+ afterEach(() => vi.unstubAllGlobals());
242
+
243
+ const target = {
244
+ kind: "Deployment",
245
+ group: "apps",
246
+ namespace: "prod",
247
+ name: "payments",
248
+ issueId: "issue-1",
249
+ };
250
+
251
+ it.each([
252
+ ["investigate further", target],
253
+ ["start fresh", { ...target, fresh: true }],
254
+ ])("preserves the %s intent on the wire", async (_name, request) => {
255
+ const fetch = vi
256
+ .fn()
257
+ .mockResolvedValue(new Response(JSON.stringify({ id: "new-run" })));
258
+ vi.stubGlobal("fetch", fetch);
259
+ await createRun(request, { agent: "hub" });
260
+ const init = fetch.mock.calls[0][1] as RequestInit;
261
+ expect(init.method).toBe("POST");
262
+ expect(JSON.parse(init.body as string)).toEqual({
263
+ ...request,
264
+ agent: "hub",
265
+ });
266
+ expect(JSON.parse(init.body as string).issueId).toBe("issue-1");
267
+ });
268
+ });
@@ -1,4 +1,4 @@
1
- // Client for the local AI-diagnose engine (OSS BYO-agent). The agent CLI runs
1
+ // Client for local AI investigations (OSS BYO-agent). The agent CLI runs
2
2
  // on the user's own machine/subscription against Radar's MCP; this just starts
3
3
  // the investigation and consumes its SSE event stream.
4
4
  import { getApiBase, getAuthHeaders, getCredentialsMode } from "./config";
@@ -18,7 +18,7 @@ export interface AgentInfo {
18
18
  export interface AgentsResponse {
19
19
  agents: AgentInfo[];
20
20
  enabled: boolean;
21
- // eligible: this run mode supports local BYO-agent diagnosis (no proxy/OIDC
21
+ // eligible: this run mode supports local BYO-agent investigations (no proxy/OIDC
22
22
  // auth, /mcp mounted) — true even when no agent is installed. Lets the UI tell
23
23
  // "install an agent to enable this" (eligible && !enabled) apart from "not
24
24
  // available here" (auth/cloud/--no-mcp). Absent on older servers / embed hosts.
@@ -37,13 +37,24 @@ export interface DiagnoseStep {
37
37
  ms?: number;
38
38
  summary?: string; // input args (on running)
39
39
  result?: string; // result text (on done), capped
40
+ evidenceRef?: string; // server-issued reference used to bind a root cause to this exact result
41
+ // Server validated Radar's uncapped producer result and this retained result
42
+ // as its exact capped derivative.
43
+ radarEvidence?: boolean;
44
+ isError?: boolean; // authoritative agent-host result; absent means unknown
40
45
  truncated?: boolean; // result was capped — payload shown/copied is partial
41
46
  }
42
47
 
48
+ export interface RootCauseEvidence {
49
+ status: "linked" | "missing" | "invalid";
50
+ refs?: string[];
51
+ }
52
+
43
53
  export interface Diagnosis {
44
54
  healthy?: boolean;
45
55
  inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
46
56
  rootCause: string;
57
+ rootCauseEvidence?: RootCauseEvidence;
47
58
  report: string;
48
59
  remediation: string[];
49
60
  recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
@@ -72,8 +83,19 @@ export interface ResourceHealthSignal {
72
83
  auditFindings?: HealthLine[];
73
84
  }
74
85
 
86
+ export type ApplyMutationOutcome = "confirmed" | "failed" | "unknown";
87
+
75
88
  export interface DiagnoseStreamEvent {
76
- type: "turn" | "phase" | "step" | "thinking" | "done" | "error" | "closed";
89
+ type:
90
+ | "turn"
91
+ | "phase"
92
+ | "step"
93
+ | "thinking"
94
+ | "done"
95
+ | "error"
96
+ | "closed"
97
+ | "history_unavailable"
98
+ | "replay_complete";
77
99
  phase?: string;
78
100
  step?: DiagnoseStep;
79
101
  token?: string;
@@ -81,6 +103,16 @@ export interface DiagnoseStreamEvent {
81
103
  error?: string;
82
104
  question?: string; // on "turn"
83
105
  apply?: boolean; // on "turn"
106
+ verify?: boolean; // on "turn": explicit post-change re-check
107
+ explainAssessment?: number; // on "turn": originating assessment done-event sequence
108
+ // Evidence-backed mutation truth on an apply turn's terminal event. Only
109
+ // "confirmed" means a Radar write tool authoritatively reported success.
110
+ applyOutcome?: ApplyMutationOutcome;
111
+ // On an apply terminal event, the server durably queued the adjacent
112
+ // read-only verification turn. This prevents an idle control flash between
113
+ // the two SSE events without inferring lifecycle from copy.
114
+ verificationScheduled?: boolean;
115
+ retryable?: boolean; // on "history_unavailable": keep native EventSource reconnect alive
84
116
  actor?: string; // human author on shared hosted transcripts
85
117
  }
86
118
 
@@ -90,6 +122,8 @@ export interface DiagnoseStreamEvent {
90
122
  export interface RunSummary {
91
123
  id: string;
92
124
  kind: string;
125
+ /** Kubernetes API group; empty means the core API group. */
126
+ group: string;
93
127
  namespace: string;
94
128
  name: string;
95
129
  /** The issue this session is for, on hosts that key sessions by issue. Always
@@ -153,6 +187,8 @@ const RUNS = () => `${getApiBase()}/diagnose/runs`;
153
187
  export async function createRun(
154
188
  target: {
155
189
  kind: string;
190
+ /** Kubernetes API group; empty means the core API group. */
191
+ group: string;
156
192
  namespace: string;
157
193
  name: string;
158
194
  // Associates the session with the issue it was started from, for hosts that
@@ -256,7 +292,13 @@ export async function clearHistory(): Promise<void> {
256
292
  // addTurn appends a follow-up (question) or an apply turn (apply + confirmed fix).
257
293
  export async function addTurn(
258
294
  id: string,
259
- body: { question?: string; apply?: boolean; fix?: string },
295
+ body: {
296
+ question?: string;
297
+ apply?: boolean;
298
+ fix?: string;
299
+ verify?: boolean;
300
+ explainAssessment?: number;
301
+ },
260
302
  ): Promise<void> {
261
303
  const res = await fetch(`${RUNS()}/${id}/turns`, {
262
304
  method: "POST",
@@ -277,8 +319,11 @@ export async function stopRun(id: string): Promise<void> {
277
319
  }
278
320
 
279
321
  export interface SubscribeHandlers {
280
- onEvent: (ev: DiagnoseStreamEvent) => void;
281
- onClosed?: () => void; // the run can no longer produce events (stale/evicted)
322
+ onEvent: (ev: DiagnoseStreamEvent, sequence?: number) => void;
323
+ // EventSource fires open before each initial/reconnect replay. Together with
324
+ // replay_complete this brackets history so consumers can rebuild silently.
325
+ onReplayStart?: () => void;
326
+ onClosed?: (reason: "run_closed" | "unavailable") => void;
282
327
  }
283
328
 
284
329
  /**
@@ -302,6 +347,11 @@ export function subscribeRun(
302
347
  es.close();
303
348
  };
304
349
  const dispatch = (e: MessageEvent) => {
350
+ // close() can run while an earlier SSE callback is being dispatched. Ignore
351
+ // any already-queued trailing frame (notably the server's `closed` sentinel
352
+ // after a permanent history_unavailable) so it cannot replace the specific
353
+ // failure explanation with a generic eviction state.
354
+ if (closed) return;
305
355
  let ev: DiagnoseStreamEvent;
306
356
  try {
307
357
  ev = JSON.parse(e.data);
@@ -310,11 +360,21 @@ export function subscribeRun(
310
360
  }
311
361
  if (ev.type === "closed") {
312
362
  close();
313
- handlers.onClosed?.();
363
+ handlers.onClosed?.("run_closed");
314
364
  return;
315
365
  }
316
- handlers.onEvent(ev);
366
+ const sequence = Number(e.lastEventId);
367
+ handlers.onEvent(
368
+ ev,
369
+ Number.isSafeInteger(sequence) && sequence > 0 ? sequence : undefined,
370
+ );
371
+ // Hydration failures stay inside the SSE protocol. Retryable failures end
372
+ // this response and let native EventSource reconnect without advancing its
373
+ // cursor; a permanent failure must stop that reconnect loop after the UI
374
+ // receives the explanatory event.
375
+ if (ev.type === "history_unavailable" && ev.retryable !== true) close();
317
376
  };
377
+ es.onopen = () => handlers.onReplayStart?.();
318
378
  for (const t of [
319
379
  "turn",
320
380
  "phase",
@@ -323,10 +383,13 @@ export function subscribeRun(
323
383
  "done",
324
384
  "error",
325
385
  "closed",
386
+ "history_unavailable",
387
+ "replay_complete",
326
388
  ] as const) {
327
389
  es.addEventListener(t, dispatch);
328
390
  }
329
391
  es.onerror = () => {
392
+ if (closed) return;
330
393
  // A permanent failure (readyState CLOSED) means the run is gone — a 404 because
331
394
  // it was evicted (retention cap) or lost on a server restart. Surface it as
332
395
  // closed so the view shows a "no longer available" state instead of a silent
@@ -334,7 +397,7 @@ export function subscribeRun(
334
397
  // replays only what we missed), so leave those to EventSource.
335
398
  if (es.readyState === EventSource.CLOSED) {
336
399
  close();
337
- handlers.onClosed?.();
400
+ handlers.onClosed?.("unavailable");
338
401
  }
339
402
  };
340
403
  return close;
@@ -1,5 +1,5 @@
1
1
  // The agent/model/effort controls + a clear-history action for the Settings
2
- // "AI diagnose" tab. Controlled by the dialog: it edits a STAGED draft committed
2
+ // "AI investigations" tab. Controlled by the dialog: it edits a STAGED draft committed
3
3
  // on Save (like the rest of Settings), not on every keystroke. The heading,
4
4
  // description, and Save button live in the dialog so this tab matches the other
5
5
  // Settings tabs' layout — this renders only the controls (no card, no heading).
@@ -52,7 +52,7 @@ function AgentRow({ agent }: { agent: AgentInstall }) {
52
52
  );
53
53
  }
54
54
 
55
- // Shown in the AI surface's Home when diagnosis is eligible in this deployment
55
+ // Shown in the AI surface's Home when investigations are eligible in this deployment
56
56
  // but not runnable yet — either no agent CLI is installed ("needs-install") or a
57
57
  // supported one appeared after Radar booted ("needs-restart"). Radar decides the
58
58
  // engine once at startup, so a fresh install needs a restart to take effect.
@@ -69,8 +69,8 @@ export function AgentSetupNotice({
69
69
  </div>
70
70
  <h3 className="text-base font-semibold text-theme-text-primary">
71
71
  {needsRestart
72
- ? "Restart Radar to enable AI diagnosis"
73
- : "Set up AI diagnosis"}
72
+ ? "Restart Radar to enable AI investigations"
73
+ : "Set up AI investigations"}
74
74
  </h3>
75
75
  <p className="mt-1 text-sm text-theme-text-secondary">
76
76
  {needsRestart ? (
@@ -109,8 +109,8 @@ export function AgentSetupNotice({
109
109
  </button>
110
110
 
111
111
  <p className="mt-4 text-xs text-theme-text-tertiary">
112
- The agent reads this cluster through Radar and finds the root cause. It
113
- never leaves your machine.
112
+ Your agent runs locally. Resource details and logs are sent to its model
113
+ provider under your account, not to Radar.
114
114
  </p>
115
115
  </div>
116
116
  );
@@ -0,0 +1,72 @@
1
+ import type { ReactNode } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { ApplyDialog } from "./parts";
5
+ import { AgentSetupNotice } from "./AgentSetupNotice";
6
+
7
+ vi.mock("@skyhook-io/k8s-ui/components/ui/DialogPortal", () => ({
8
+ DialogPortal: ({ open, children }: { open: boolean; children: ReactNode }) =>
9
+ open ? children : null,
10
+ }));
11
+
12
+ describe("Apply confirmation context", () => {
13
+ it.each([
14
+ "gke_project-a_us-east1-b_nonprod",
15
+ "gke_project-b_us-east1-b_nonprod",
16
+ ])(
17
+ "identifies the exact destination %s even when display names collide",
18
+ (context) => {
19
+ const html = renderToStaticMarkup(
20
+ <ApplyDialog
21
+ open
22
+ onClose={() => {}}
23
+ onConfirm={() => {}}
24
+ agentLabel="Local agent"
25
+ resourceLabel="Deployment dev/api"
26
+ context={context}
27
+ fix="Only after verifying credentials, update the Secret reference."
28
+ />,
29
+ );
30
+ expect(html).toContain("Cluster:");
31
+ expect(html).toContain("nonprod");
32
+ expect(html).toContain(context);
33
+ expect(html).toContain("Deployment dev/api");
34
+ expect(html).toContain("Proposed change");
35
+ expect(html).not.toContain("What will happen");
36
+ expect(html).not.toContain("max-h-48");
37
+ expect(html).toContain("Only after verifying credentials");
38
+ },
39
+ );
40
+
41
+ it("keeps managed-resource acknowledgement and low-confidence warning", () => {
42
+ const html = renderToStaticMarkup(
43
+ <ApplyDialog
44
+ open
45
+ onClose={() => {}}
46
+ onConfirm={() => {}}
47
+ agentLabel="Local agent"
48
+ resourceLabel="Deployment dev/api"
49
+ context="kind-dev"
50
+ fix="Update its configuration"
51
+ managedBy="Argo CD"
52
+ confidence={0.3}
53
+ />,
54
+ );
55
+ expect(html).toContain("I understand Argo CD may revert this");
56
+ const applyButton = html
57
+ .match(/<button\b[^>]*>[\s\S]*?<\/button>/g)
58
+ ?.find((button) => button.includes("Apply fix"));
59
+ expect(applyButton).toContain('disabled=""');
60
+ expect(html).toContain("low confidence");
61
+ expect(html.match(/kubeconfig/g)).toHaveLength(1);
62
+ });
63
+ });
64
+
65
+ it("distinguishes local execution from sending resource data to the model provider", () => {
66
+ const html = renderToStaticMarkup(
67
+ <AgentSetupNotice setupState="needs-install" />,
68
+ );
69
+ expect(html).toContain("Your agent runs locally");
70
+ expect(html).toContain("model provider under your account, not to Radar");
71
+ expect(html).not.toContain("never leaves your machine");
72
+ });