@skyhook-io/radar-app 1.13.3 → 1.13.5

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 (45) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +68 -23
  3. package/src/RadarApp.tsx +17 -1
  4. package/src/api/client.ts +28 -10
  5. package/src/api/diagnose.ts +61 -15
  6. package/src/components/ConnectionErrorView.test.tsx +30 -0
  7. package/src/components/ConnectionErrorView.tsx +31 -19
  8. package/src/components/diagnose/AgentCase.tsx +131 -0
  9. package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
  10. package/src/components/diagnose/DiagnoseContext.tsx +402 -77
  11. package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
  12. package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
  13. package/src/components/diagnose/Home.test.tsx +23 -1
  14. package/src/components/diagnose/Home.tsx +49 -3
  15. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
  16. package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
  17. package/src/components/diagnose/InvestigationView.tsx +227 -5
  18. package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
  19. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  20. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  21. package/src/components/diagnose/investigationCase.ts +439 -0
  22. package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
  23. package/src/components/diagnose/investigationEvidence.ts +2246 -166
  24. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  27. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  28. package/src/components/diagnose/investigationMetrics.ts +393 -0
  29. package/src/components/diagnose/investigationSourceFocus.ts +6 -0
  30. package/src/components/diagnose/investigationState.test.ts +322 -0
  31. package/src/components/diagnose/investigationState.ts +147 -25
  32. package/src/components/diagnose/parts.test.tsx +537 -1
  33. package/src/components/diagnose/parts.tsx +486 -42
  34. package/src/components/resource/HPACharts.render.test.tsx +77 -0
  35. package/src/components/resource/HPACharts.tsx +32 -25
  36. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  37. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  38. package/src/components/resources/PodFilePreview.tsx +394 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  40. package/src/components/resources/ResourcesView.tsx +27 -2
  41. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  42. package/src/context/DiagnoseCustomization.tsx +14 -2
  43. package/src/index.ts +8 -5
  44. package/src/utils/shell-safe.test.ts +25 -1
  45. package/src/utils/shell-safe.ts +16 -0
@@ -8,6 +8,12 @@ import {
8
8
  Timeline,
9
9
  TurnView,
10
10
  appendThinking,
11
+ remediationCommands,
12
+ mergeStartupSignal,
13
+ middleTruncate,
14
+ runningElapsedLabel,
15
+ toolDurationLabel,
16
+ toolErrorReason,
11
17
  upsertTool,
12
18
  type TimelineItem,
13
19
  type Turn,
@@ -16,6 +22,7 @@ import type {
16
22
  AgentInfo,
17
23
  Diagnosis,
18
24
  DiagnoseStep,
25
+ DiagnoseStreamEvent,
19
26
  ExecutionProfile,
20
27
  } from "../../api/diagnose";
21
28
  import { investigationEvidenceSourceId } from "./investigationEvidence";
@@ -40,7 +47,9 @@ it("keeps earlier remediation copyable without suggesting it is executable", ()
40
47
  />,
41
48
  );
42
49
  expect(html).toContain("Restore the required Secret");
43
- expect(html).toContain("Copy remediation step 1");
50
+ // Prose has nothing worth copying; only a command gets a copy button.
51
+ expect(html).not.toContain("Copy remediation step");
52
+ expect(html).not.toContain("Copy command");
44
53
  expect(html).toContain("Reassess before applying");
45
54
  expect(html).not.toContain("Apply…");
46
55
  expect(html).not.toContain("ask the agent to continue");
@@ -49,6 +58,48 @@ it("keeps earlier remediation copyable without suggesting it is executable", ()
49
58
  );
50
59
  });
51
60
 
61
+ describe("remediation commands", () => {
62
+ it("extracts shell commands from inline code and fenced blocks, not identifiers", () => {
63
+ expect(
64
+ remediationCommands(
65
+ "Run `kubectl rollout undo deployment/api -n dev --to-revision=7` to revert; the `MONGO_PASSWORD` key in `Secret/api` stays.",
66
+ ),
67
+ ).toEqual(["kubectl rollout undo deployment/api -n dev --to-revision=7"]);
68
+ expect(
69
+ remediationCommands(
70
+ "Then:\n```bash\nhelm rollback prometheus 1 -n opencost\nkubectl -n opencost get pods\n```\nand check `mongosh --host nonprod-boxer.ax6bh.mongodb.net`.",
71
+ ),
72
+ ).toEqual([
73
+ "helm rollback prometheus 1 -n opencost\nkubectl -n opencost get pods",
74
+ "mongosh --host nonprod-boxer.ax6bh.mongodb.net",
75
+ ]);
76
+ expect(
77
+ remediationCommands("Pin the image to a digest instead of `latest`."),
78
+ ).toEqual([]);
79
+ });
80
+
81
+ it("offers one copy button per command in a step", () => {
82
+ const html = renderToStaticMarkup(
83
+ <ResultCard
84
+ diagnosis={
85
+ {
86
+ rootCause: "Stale credential",
87
+ report: "Assessment",
88
+ remediation: [
89
+ "Roll back with `kubectl rollout undo deployment/api -n dev` and then verify with `kubectl -n dev get pods`.",
90
+ ],
91
+ } as Diagnosis
92
+ }
93
+ section="actions"
94
+ compactActions
95
+ />,
96
+ );
97
+ expect(html).toContain("Copy command 1 of step 1");
98
+ expect(html).toContain("Copy command 2 of step 1");
99
+ expect(html).not.toContain("Copy remediation step");
100
+ });
101
+ });
102
+
52
103
  describe("explanation placement", () => {
53
104
  it("gives analysis and explanation separate animated disclosure regions", () => {
54
105
  const html = renderToStaticMarkup(
@@ -332,6 +383,385 @@ describe("Timeline reasoning density", () => {
332
383
  });
333
384
  });
334
385
 
386
+ describe("Timeline startup signals", () => {
387
+ const readyEvent: Pick<
388
+ DiagnoseStreamEvent,
389
+ "phase" | "model" | "toolCount" | "mcpServers"
390
+ > = {
391
+ phase: "ready",
392
+ model: "claude-opus-5",
393
+ toolCount: 24,
394
+ mcpServers: [{ name: "radar", status: "connected" }],
395
+ };
396
+
397
+ it("reports each startup phase in place of the generic pending label", () => {
398
+ const before = renderToStaticMarkup(
399
+ <Timeline items={[]} running agentLabel="Claude Code" />,
400
+ );
401
+ expect(before).toContain("Starting investigation…");
402
+
403
+ const starting = mergeStartupSignal(undefined, { phase: "investigating" });
404
+ expect(
405
+ renderToStaticMarkup(
406
+ <Timeline
407
+ items={[]}
408
+ running
409
+ agentLabel="Claude Code"
410
+ startup={starting}
411
+ />,
412
+ ),
413
+ ).toContain("Claude Code starting…");
414
+
415
+ const connected = mergeStartupSignal(starting, { phase: "connected" });
416
+ expect(
417
+ renderToStaticMarkup(
418
+ <Timeline
419
+ items={[]}
420
+ running
421
+ agentLabel="Claude Code"
422
+ startup={connected}
423
+ />,
424
+ ),
425
+ ).toContain("Connected to Radar&#x27;s tools");
426
+
427
+ const ready = mergeStartupSignal(connected, readyEvent);
428
+ const html = renderToStaticMarkup(
429
+ <Timeline items={[]} running agentLabel="Claude Code" startup={ready} />,
430
+ );
431
+ expect(html).toContain(
432
+ "Claude Code ready · claude-opus-5 · 24 Radar tools",
433
+ );
434
+ expect(html).not.toContain("Starting investigation…");
435
+ expect(html).not.toContain("MCP server");
436
+ });
437
+
438
+ it("keeps the furthest phase when the handshake lands after the init line", () => {
439
+ const ready = mergeStartupSignal(undefined, readyEvent);
440
+ const late = mergeStartupSignal(ready, { phase: "connected" });
441
+ expect(late).toEqual({
442
+ phase: "ready",
443
+ model: "claude-opus-5",
444
+ toolCount: 24,
445
+ mcpServers: [{ name: "radar", status: "connected" }],
446
+ });
447
+ expect(mergeStartupSignal(ready, { phase: "investigating" })).toBe(late);
448
+ expect(mergeStartupSignal(undefined, { phase: "unknown" })).toBeUndefined();
449
+ });
450
+
451
+ it("yields to the tool activity once the transcript has items", () => {
452
+ const html = renderToStaticMarkup(
453
+ <Timeline
454
+ items={[
455
+ {
456
+ kind: "tool",
457
+ id: "tool-1",
458
+ tool: "get_logs",
459
+ status: "running",
460
+ animate: false,
461
+ },
462
+ ]}
463
+ running
464
+ agentLabel="Claude Code"
465
+ startup={mergeStartupSignal(undefined, readyEvent)}
466
+ />,
467
+ );
468
+ expect(html).toContain("Reading logs…");
469
+ expect(html).not.toContain("Claude Code ready");
470
+ });
471
+
472
+ it("shows no startup label on a replayed finished turn", () => {
473
+ const html = renderToStaticMarkup(
474
+ <Timeline
475
+ items={[]}
476
+ running={false}
477
+ agentLabel="Claude Code"
478
+ startup={mergeStartupSignal(undefined, readyEvent)}
479
+ />,
480
+ );
481
+ expect(html).not.toContain("Claude Code ready");
482
+ expect(html).not.toContain("Starting investigation…");
483
+ expect(html).not.toContain("Connected to Radar");
484
+ });
485
+
486
+ it("renders a bare ready label when the init facts are absent", () => {
487
+ // Hosted transports may forward only the phase name; the label and the
488
+ // warning must degrade to exactly what was reported.
489
+ const startup = mergeStartupSignal(undefined, { phase: "ready" });
490
+ const html = renderToStaticMarkup(
491
+ <Timeline
492
+ items={[]}
493
+ running
494
+ agentLabel="Claude Code"
495
+ startup={startup}
496
+ />,
497
+ );
498
+ expect(html).toContain("Claude Code ready</span>");
499
+ expect(html).not.toContain("Radar tools");
500
+ expect(html).not.toContain("MCP server");
501
+ });
502
+
503
+ it("uses conditional wording for servers whose startup state is not final", () => {
504
+ const startup = mergeStartupSignal(undefined, {
505
+ phase: "ready",
506
+ model: "claude-opus-5",
507
+ toolCount: 24,
508
+ mcpServers: [
509
+ { name: "radar", status: "connected" },
510
+ { name: "github", status: "pending" },
511
+ ],
512
+ });
513
+ const pending = renderToStaticMarkup(
514
+ <Timeline
515
+ items={[]}
516
+ running
517
+ agentLabel="Claude Code"
518
+ startup={startup}
519
+ />,
520
+ );
521
+ expect(pending).toContain("is pending");
522
+ expect(pending).toContain("may not have been available");
523
+ expect(pending).not.toContain("ran this turn without");
524
+
525
+ const failed = renderToStaticMarkup(
526
+ <Timeline
527
+ items={[]}
528
+ running
529
+ agentLabel="Claude Code"
530
+ startup={mergeStartupSignal(undefined, {
531
+ phase: "ready",
532
+ mcpServers: [
533
+ { name: "radar", status: "connected" },
534
+ { name: "github", status: "failed" },
535
+ ],
536
+ })}
537
+ />,
538
+ );
539
+ expect(failed).toContain("failed to connect");
540
+ expect(failed).toContain("ran this turn without those tools");
541
+ });
542
+
543
+ it("warns when the CLI reports an MCP server that is not connected", () => {
544
+ const startup = mergeStartupSignal(undefined, {
545
+ phase: "ready",
546
+ model: "claude-opus-5",
547
+ toolCount: 0,
548
+ mcpServers: [{ name: "radar", status: "failed" }],
549
+ });
550
+ const live = renderToStaticMarkup(
551
+ <Timeline
552
+ items={[]}
553
+ running
554
+ agentLabel="Claude Code"
555
+ startup={startup}
556
+ />,
557
+ );
558
+ expect(live).toContain("Claude Code ready · claude-opus-5 · 0 Radar tools");
559
+ expect(live).toContain("failed to connect");
560
+ expect(live).toContain("had no Radar tools this turn");
561
+
562
+ // The warning is a fact of the run and survives replay; the label does not.
563
+ const replayed = renderToStaticMarkup(
564
+ <Timeline
565
+ items={[{ kind: "thinking", text: "done", animate: false }]}
566
+ running={false}
567
+ agentLabel="Claude Code"
568
+ startup={startup}
569
+ />,
570
+ );
571
+ expect(replayed).toContain("failed to connect");
572
+ expect(replayed).not.toContain("Claude Code ready");
573
+ });
574
+ });
575
+
576
+ describe("running status elapsed counter", () => {
577
+ it("is a row-level figure, never part of the phase label", () => {
578
+ expect(runningElapsedLabel(0)).toBeUndefined();
579
+ expect(runningElapsedLabel(2)).toBeUndefined();
580
+ expect(runningElapsedLabel(3)).toBe("3s elapsed");
581
+ expect(runningElapsedLabel(19)).toBe("19s elapsed");
582
+
583
+ const html = renderToStaticMarkup(
584
+ <Timeline
585
+ items={[]}
586
+ running
587
+ agentLabel="Claude Code"
588
+ startup={mergeStartupSignal(undefined, { phase: "connected" })}
589
+ />,
590
+ );
591
+ const label = html.match(/<span class="ai-shimmer[^"]*">([^<]*)</);
592
+ expect(label?.[1]).toBe("Connected to Radar&#x27;s tools");
593
+ expect(label?.[1]).not.toMatch(/\d+s/);
594
+ });
595
+ });
596
+
597
+ describe("tool row duration and failure reason", () => {
598
+ const doneStep = (
599
+ overrides: Partial<Extract<TimelineItem, { kind: "tool" }>>,
600
+ ): TimelineItem => ({
601
+ kind: "tool",
602
+ id: "tool-1",
603
+ tool: "get_events",
604
+ status: "done",
605
+ summary: '{"namespace":"dev"}',
606
+ result: '{"events":[]}',
607
+ isError: false,
608
+ animate: false,
609
+ ...overrides,
610
+ });
611
+
612
+ it("hides sub-threshold durations and shows whole seconds above it", () => {
613
+ expect(toolDurationLabel(undefined)).toBeUndefined();
614
+ expect(toolDurationLabel(125)).toBeUndefined();
615
+ expect(toolDurationLabel(1999)).toBeUndefined();
616
+ expect(toolDurationLabel(2000)).toBe("2s");
617
+ expect(toolDurationLabel(3400)).toBe("3s");
618
+ expect(toolDurationLabel(12600)).toBe("13s");
619
+
620
+ const quick = renderToStaticMarkup(
621
+ <ThemeProvider>
622
+ <Timeline items={[doneStep({ ms: 125 })]} running={false} />
623
+ </ThemeProvider>,
624
+ );
625
+ // The only millisecond figure is the expanded result header, not the row.
626
+ expect(quick.match(/125ms/g)).toHaveLength(1);
627
+ expect(quick.indexOf("125ms")).toBeGreaterThan(
628
+ quick.indexOf("Original result"),
629
+ );
630
+ expect(quick).not.toMatch(/>\d+s</);
631
+
632
+ const slow = renderToStaticMarkup(
633
+ <ThemeProvider>
634
+ <Timeline items={[doneStep({ ms: 3400 })]} running={false} />
635
+ </ThemeProvider>,
636
+ );
637
+ expect(slow).toContain(">3s<");
638
+ expect(slow.indexOf(">3s<")).toBeLessThan(slow.indexOf("Original result"));
639
+ expect(slow.match(/3400ms/g)).toHaveLength(1);
640
+ });
641
+
642
+ it("keeps the exact milliseconds in the expanded result header", () => {
643
+ const sourceId = investigationEvidenceSourceId(0, "tool-1");
644
+ const html = renderToStaticMarkup(
645
+ <ThemeProvider>
646
+ <Timeline
647
+ items={[doneStep({ ms: 3400 })]}
648
+ running={false}
649
+ turnIndex={0}
650
+ sourceRevealRequest={{ sourceId, requestId: 1 }}
651
+ />
652
+ </ThemeProvider>,
653
+ );
654
+ expect(html).toContain('aria-expanded="true"');
655
+ expect(html).toContain("Original result");
656
+ expect(html).toContain("3400ms");
657
+ });
658
+
659
+ it("extracts a one-line reason from the producer's error text", () => {
660
+ expect(
661
+ toolErrorReason('\ninvalid duration "30d": time: unknown unit "d"\nmore'),
662
+ ).toBe('invalid duration "30d": time: unknown unit "d"');
663
+ expect(
664
+ toolErrorReason(JSON.stringify({ error: 'resource not found: pod "x"' })),
665
+ ).toBe('resource not found: pod "x"');
666
+ expect(toolErrorReason("")).toBeUndefined();
667
+ expect(toolErrorReason(" \n ")).toBeUndefined();
668
+ expect(toolErrorReason('{"error":""}')).toBeUndefined();
669
+ // Radar's not-found hints are for the agent; the row keeps what failed.
670
+ expect(
671
+ toolErrorReason(
672
+ 'resource not found: secret "project-infra" not found — found Deployment autopush/project-infra — retry with kind=deployment; or use search',
673
+ ),
674
+ ).toBe('resource not found: secret "project-infra" not found');
675
+ });
676
+
677
+ it("middle-truncates a long reason so its identifier tail survives", () => {
678
+ const short = 'resource not found: secret "dev/does-not-exist" not found';
679
+ expect(middleTruncate(short)).toBe(short);
680
+ const long =
681
+ "failed to get logs for autopush/project-infra-68c7b766dc-g2xlg: container is waiting to start: CreateContainerConfigError for pod project-infra-68c7b766dc-g2xlg";
682
+ const cut = middleTruncate(long);
683
+ expect(cut.length).toBeLessThanOrEqual(100);
684
+ expect(cut).toContain("…");
685
+ expect(cut.startsWith("failed to get logs for autopush")).toBe(true);
686
+ expect(cut.endsWith("project-infra-68c7b766dc-g2xlg")).toBe(true);
687
+ expect(middleTruncate("abcdefghij", 6, 2)).toBe("abc…ij");
688
+ });
689
+
690
+ it("lets the arguments give way before the reason on a collapsed failed row", () => {
691
+ const html = renderToStaticMarkup(
692
+ <ThemeProvider>
693
+ <Timeline
694
+ items={[
695
+ doneStep({
696
+ isError: true,
697
+ summary:
698
+ '{"kind":"Secret","namespace":"dev","name":"does-not-exist"}',
699
+ result:
700
+ 'resource not found: secret "dev/does-not-exist" not found — found Deployment dev/api — retry with kind=deployment',
701
+ }),
702
+ ]}
703
+ running={false}
704
+ />
705
+ </ThemeProvider>,
706
+ );
707
+ const args = html.match(/<span class="([^"]*)">kind=Secret/);
708
+ expect(args?.[1]).toContain("flex-1");
709
+ expect(args?.[1]).toContain("truncate");
710
+ const reason = html.match(
711
+ /<span class="investigation-tool-reason ([^"]*)">([^<]*)</,
712
+ );
713
+ expect(reason?.[1]).toContain("shrink-0");
714
+ expect(reason?.[2]).toBe(
715
+ "resource not found: secret &quot;dev/does-not-exist&quot; not found",
716
+ );
717
+ });
718
+
719
+ it("shows the failure reason inline on a failed row only when the producer gave one", () => {
720
+ const failed = renderToStaticMarkup(
721
+ <ThemeProvider>
722
+ <Timeline
723
+ items={[
724
+ doneStep({
725
+ isError: true,
726
+ result:
727
+ '\nfailed to get logs for dev/api: container "api" is waiting to start: CreateContainerConfigError',
728
+ }),
729
+ ]}
730
+ running={false}
731
+ />
732
+ </ThemeProvider>,
733
+ );
734
+ expect(failed).toContain("investigation-tool-reason");
735
+ expect(failed).toContain(
736
+ "failed to get logs for dev/api: container &quot;api&quot; is waiting to start",
737
+ );
738
+ expect(failed.indexOf("investigation-tool-reason")).toBeGreaterThan(
739
+ failed.indexOf("namespace=dev"),
740
+ );
741
+
742
+ const silent = renderToStaticMarkup(
743
+ <ThemeProvider>
744
+ <Timeline
745
+ items={[doneStep({ isError: true, result: undefined })]}
746
+ running={false}
747
+ />
748
+ </ThemeProvider>,
749
+ );
750
+ expect(silent).toContain('aria-label="Tool failed"');
751
+ expect(silent).not.toContain("investigation-tool-reason");
752
+
753
+ const succeeded = renderToStaticMarkup(
754
+ <ThemeProvider>
755
+ <Timeline
756
+ items={[doneStep({ isError: false, result: "plain text result" })]}
757
+ running={false}
758
+ />
759
+ </ThemeProvider>,
760
+ );
761
+ expect(succeeded).not.toContain("investigation-tool-reason");
762
+ });
763
+ });
764
+
335
765
  function renderAgent(
336
766
  agent: string,
337
767
  profiles: ExecutionProfile[],
@@ -581,6 +1011,72 @@ describe("ResultCard conclusion states", () => {
581
1011
  expect(html).not.toContain("border-emerald-500/30");
582
1012
  });
583
1013
 
1014
+ it("lists a step's commands in reading order across inline and fenced code", () => {
1015
+ const step =
1016
+ "Roll it with `kubectl -n dev rollout restart deploy/api`, then verify:\n```bash\nkubectl -n dev rollout status deploy/api\n```";
1017
+ expect(remediationCommands(step)).toEqual([
1018
+ "kubectl -n dev rollout restart deploy/api",
1019
+ "kubectl -n dev rollout status deploy/api",
1020
+ ]);
1021
+ });
1022
+
1023
+ it("reframes the conflict banner when the agent explained every adverse card", () => {
1024
+ const html = renderToStaticMarkup(
1025
+ <ResultCard
1026
+ diagnosis={diagnosis({
1027
+ healthy: true,
1028
+ report: "The workload appears ready.",
1029
+ })}
1030
+ evidenceConflict
1031
+ evidenceConflictExplainedBy={["CrashLoopBackOff", "Error logs"]}
1032
+ />,
1033
+ );
1034
+
1035
+ // An explanation reframes Radar's finding; it never replaces it with
1036
+ // reassurance. The banner stays a warning, still says the evidence is
1037
+ // there, and points at the notes.
1038
+ expect(html).toContain(
1039
+ "Agent reports no active problem; adverse evidence remains",
1040
+ );
1041
+ expect(html).toContain("Radar captured evidence of an active problem");
1042
+ expect(html).toContain("the note on CrashLoopBackOff and Error logs");
1043
+ expect(html).not.toContain("Assessment conflicts with captured evidence");
1044
+ expect(html).toContain("border-amber-500/40");
1045
+ expect(html).not.toContain("border-emerald-500/30");
1046
+ expect(html).not.toContain("bg-accent/5");
1047
+ // Never claims a severity tier it did not check.
1048
+ expect(html).not.toContain("warning-level");
1049
+
1050
+ const unexplained = renderToStaticMarkup(
1051
+ <ResultCard
1052
+ diagnosis={diagnosis({
1053
+ healthy: true,
1054
+ report: "The workload appears ready.",
1055
+ })}
1056
+ evidenceConflict
1057
+ evidenceConflictExplainedBy={[]}
1058
+ />,
1059
+ );
1060
+ expect(unexplained).toContain(
1061
+ "Assessment conflicts with captured evidence",
1062
+ );
1063
+
1064
+ // Coverage is a separate fact from the conflict; an explanation of the
1065
+ // conflict must not quietly retire it.
1066
+ const limited = renderToStaticMarkup(
1067
+ <ResultCard
1068
+ diagnosis={diagnosis({
1069
+ healthy: true,
1070
+ report: "The workload appears ready.",
1071
+ })}
1072
+ evidenceConflict
1073
+ evidenceConflictExplainedBy={["CrashLoopBackOff"]}
1074
+ coverageLimited
1075
+ />,
1076
+ );
1077
+ expect(limited).toContain("Evidence coverage is also limited");
1078
+ });
1079
+
584
1080
  it("keeps a follow-up framed as an answer rather than a new conclusion", () => {
585
1081
  const html = renderToStaticMarkup(
586
1082
  <ResultCard
@@ -1030,4 +1526,44 @@ describe("assessment provenance disclosure", () => {
1030
1526
  'id="investigation-evidence-',
1031
1527
  );
1032
1528
  });
1529
+
1530
+ it("shows a claim here when its card was withheld, instead of counting it as visible", () => {
1531
+ // A card-placed note whose card the pane withheld renders nowhere unless
1532
+ // this row falls back to it, while the count still claimed it was on a
1533
+ // card — a receipt for something the reader cannot see.
1534
+ const investigationCase = {
1535
+ items: [
1536
+ {
1537
+ index: 0,
1538
+ role: "context",
1539
+ claim: "WITHHELD_CLAIM",
1540
+ source: { id: "s1", tool: "issues", args: "{}" },
1541
+ placement: "card",
1542
+ groupId: "g-hidden",
1543
+ },
1544
+ ],
1545
+ } as unknown as Parameters<
1546
+ typeof AssessmentSources
1547
+ >[0]["investigationCase"];
1548
+
1549
+ const hidden = renderToStaticMarkup(
1550
+ <AssessmentSources
1551
+ investigationCase={investigationCase}
1552
+ renderedGroupIds={new Set<string>()}
1553
+ onViewSource={noop}
1554
+ />,
1555
+ );
1556
+ expect(hidden).toContain("WITHHELD_CLAIM");
1557
+ expect(hidden).not.toContain("agent note on an evidence card");
1558
+
1559
+ const shown = renderToStaticMarkup(
1560
+ <AssessmentSources
1561
+ investigationCase={investigationCase}
1562
+ renderedGroupIds={new Set(["g-hidden"])}
1563
+ onViewSource={noop}
1564
+ />,
1565
+ );
1566
+ expect(shown).not.toContain("WITHHELD_CLAIM");
1567
+ expect(shown).toContain("1 agent note on an evidence card");
1568
+ });
1033
1569
  });