@skyhook-io/radar-app 1.13.1 → 1.13.3

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 +74 -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 +1913 -395
  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 +92 -0
  28. package/src/components/diagnose/investigationExplanation.ts +29 -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.3",
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";
@@ -13,12 +13,14 @@ export interface AgentInfo {
13
13
  profiles?: ExecutionProfile[];
14
14
  consentSurfaces?: Partial<Record<ExecutionProfile, string>>;
15
15
  hosted?: boolean;
16
+ /** Hosted backends opt in only when they implement assessment-bound, tool-free explanations. */
17
+ assessmentExplanations?: boolean;
16
18
  }
17
19
 
18
20
  export interface AgentsResponse {
19
21
  agents: AgentInfo[];
20
22
  enabled: boolean;
21
- // eligible: this run mode supports local BYO-agent diagnosis (no proxy/OIDC
23
+ // eligible: this run mode supports local BYO-agent investigations (no proxy/OIDC
22
24
  // auth, /mcp mounted) — true even when no agent is installed. Lets the UI tell
23
25
  // "install an agent to enable this" (eligible && !enabled) apart from "not
24
26
  // available here" (auth/cloud/--no-mcp). Absent on older servers / embed hosts.
@@ -37,13 +39,24 @@ export interface DiagnoseStep {
37
39
  ms?: number;
38
40
  summary?: string; // input args (on running)
39
41
  result?: string; // result text (on done), capped
42
+ evidenceRef?: string; // server-issued reference used to bind a root cause to this exact result
43
+ // Server validated Radar's uncapped producer result and this retained result
44
+ // as its exact capped derivative.
45
+ radarEvidence?: boolean;
46
+ isError?: boolean; // authoritative agent-host result; absent means unknown
40
47
  truncated?: boolean; // result was capped — payload shown/copied is partial
41
48
  }
42
49
 
50
+ export interface RootCauseEvidence {
51
+ status: "linked" | "missing" | "invalid";
52
+ refs?: string[];
53
+ }
54
+
43
55
  export interface Diagnosis {
44
56
  healthy?: boolean;
45
57
  inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
46
58
  rootCause: string;
59
+ rootCauseEvidence?: RootCauseEvidence;
47
60
  report: string;
48
61
  remediation: string[];
49
62
  recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
@@ -72,8 +85,19 @@ export interface ResourceHealthSignal {
72
85
  auditFindings?: HealthLine[];
73
86
  }
74
87
 
88
+ export type ApplyMutationOutcome = "confirmed" | "failed" | "unknown";
89
+
75
90
  export interface DiagnoseStreamEvent {
76
- type: "turn" | "phase" | "step" | "thinking" | "done" | "error" | "closed";
91
+ type:
92
+ | "turn"
93
+ | "phase"
94
+ | "step"
95
+ | "thinking"
96
+ | "done"
97
+ | "error"
98
+ | "closed"
99
+ | "history_unavailable"
100
+ | "replay_complete";
77
101
  phase?: string;
78
102
  step?: DiagnoseStep;
79
103
  token?: string;
@@ -81,6 +105,16 @@ export interface DiagnoseStreamEvent {
81
105
  error?: string;
82
106
  question?: string; // on "turn"
83
107
  apply?: boolean; // on "turn"
108
+ verify?: boolean; // on "turn": explicit post-change re-check
109
+ explainAssessment?: number; // on "turn": originating assessment done-event sequence
110
+ // Evidence-backed mutation truth on an apply turn's terminal event. Only
111
+ // "confirmed" means a Radar write tool authoritatively reported success.
112
+ applyOutcome?: ApplyMutationOutcome;
113
+ // On an apply terminal event, the server durably queued the adjacent
114
+ // read-only verification turn. This prevents an idle control flash between
115
+ // the two SSE events without inferring lifecycle from copy.
116
+ verificationScheduled?: boolean;
117
+ retryable?: boolean; // on "history_unavailable": keep native EventSource reconnect alive
84
118
  actor?: string; // human author on shared hosted transcripts
85
119
  }
86
120
 
@@ -90,6 +124,8 @@ export interface DiagnoseStreamEvent {
90
124
  export interface RunSummary {
91
125
  id: string;
92
126
  kind: string;
127
+ /** Kubernetes API group; empty means the core API group. */
128
+ group: string;
93
129
  namespace: string;
94
130
  name: string;
95
131
  /** The issue this session is for, on hosts that key sessions by issue. Always
@@ -153,6 +189,8 @@ const RUNS = () => `${getApiBase()}/diagnose/runs`;
153
189
  export async function createRun(
154
190
  target: {
155
191
  kind: string;
192
+ /** Kubernetes API group; empty means the core API group. */
193
+ group: string;
156
194
  namespace: string;
157
195
  name: string;
158
196
  // Associates the session with the issue it was started from, for hosts that
@@ -256,7 +294,13 @@ export async function clearHistory(): Promise<void> {
256
294
  // addTurn appends a follow-up (question) or an apply turn (apply + confirmed fix).
257
295
  export async function addTurn(
258
296
  id: string,
259
- body: { question?: string; apply?: boolean; fix?: string },
297
+ body: {
298
+ question?: string;
299
+ apply?: boolean;
300
+ fix?: string;
301
+ verify?: boolean;
302
+ explainAssessment?: number;
303
+ },
260
304
  ): Promise<void> {
261
305
  const res = await fetch(`${RUNS()}/${id}/turns`, {
262
306
  method: "POST",
@@ -277,8 +321,11 @@ export async function stopRun(id: string): Promise<void> {
277
321
  }
278
322
 
279
323
  export interface SubscribeHandlers {
280
- onEvent: (ev: DiagnoseStreamEvent) => void;
281
- onClosed?: () => void; // the run can no longer produce events (stale/evicted)
324
+ onEvent: (ev: DiagnoseStreamEvent, sequence?: number) => void;
325
+ // EventSource fires open before each initial/reconnect replay. Together with
326
+ // replay_complete this brackets history so consumers can rebuild silently.
327
+ onReplayStart?: () => void;
328
+ onClosed?: (reason: "run_closed" | "unavailable") => void;
282
329
  }
283
330
 
284
331
  /**
@@ -302,6 +349,11 @@ export function subscribeRun(
302
349
  es.close();
303
350
  };
304
351
  const dispatch = (e: MessageEvent) => {
352
+ // close() can run while an earlier SSE callback is being dispatched. Ignore
353
+ // any already-queued trailing frame (notably the server's `closed` sentinel
354
+ // after a permanent history_unavailable) so it cannot replace the specific
355
+ // failure explanation with a generic eviction state.
356
+ if (closed) return;
305
357
  let ev: DiagnoseStreamEvent;
306
358
  try {
307
359
  ev = JSON.parse(e.data);
@@ -310,11 +362,21 @@ export function subscribeRun(
310
362
  }
311
363
  if (ev.type === "closed") {
312
364
  close();
313
- handlers.onClosed?.();
365
+ handlers.onClosed?.("run_closed");
314
366
  return;
315
367
  }
316
- handlers.onEvent(ev);
368
+ const sequence = Number(e.lastEventId);
369
+ handlers.onEvent(
370
+ ev,
371
+ Number.isSafeInteger(sequence) && sequence > 0 ? sequence : undefined,
372
+ );
373
+ // Hydration failures stay inside the SSE protocol. Retryable failures end
374
+ // this response and let native EventSource reconnect without advancing its
375
+ // cursor; a permanent failure must stop that reconnect loop after the UI
376
+ // receives the explanatory event.
377
+ if (ev.type === "history_unavailable" && ev.retryable !== true) close();
317
378
  };
379
+ es.onopen = () => handlers.onReplayStart?.();
318
380
  for (const t of [
319
381
  "turn",
320
382
  "phase",
@@ -323,10 +385,13 @@ export function subscribeRun(
323
385
  "done",
324
386
  "error",
325
387
  "closed",
388
+ "history_unavailable",
389
+ "replay_complete",
326
390
  ] as const) {
327
391
  es.addEventListener(t, dispatch);
328
392
  }
329
393
  es.onerror = () => {
394
+ if (closed) return;
330
395
  // A permanent failure (readyState CLOSED) means the run is gone — a 404 because
331
396
  // it was evicted (retention cap) or lost on a server restart. Surface it as
332
397
  // closed so the view shows a "no longer available" state instead of a silent
@@ -334,7 +399,7 @@ export function subscribeRun(
334
399
  // replays only what we missed), so leave those to EventSource.
335
400
  if (es.readyState === EventSource.CLOSED) {
336
401
  close();
337
- handlers.onClosed?.();
402
+ handlers.onClosed?.("unavailable");
338
403
  }
339
404
  };
340
405
  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
+ });