From 3137aa1a4b4e52dc05863e552f3bb4afd69267d9 Mon Sep 17 00:00:00 2001 From: uddhavdave Date: Wed, 28 Oct 2026 15:24:48 +0000 Subject: [PATCH] fix(incidents): RCA thread, verdict and Ask display fixes (#15958) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/translations/.translation_state.json | 1 + .../alerts/IncidentRCAAnalysis.spec.ts | 33 ++++++++++- .../components/alerts/IncidentRCAAnalysis.vue | 21 +++++-- .../alerts/rca/RcaEvidenceCard.spec.ts | 24 +++++++- .../components/alerts/rca/RcaEvidenceCard.vue | 7 ++- .../alerts/rca/RcaHypotheses.spec.ts | 19 +++++++ .../components/alerts/rca/RcaHypotheses.vue | 5 +- .../components/alerts/rca/RcaInlineText.vue | 42 ++++++++++++++ .../alerts/rca/RcaThreadColumn.spec.ts | 57 +++++++++++++++++++ .../components/alerts/rca/RcaThreadColumn.vue | 27 +++++++-- .../components/alerts/rca/RcaThreadStep.vue | 10 ++-- .../alerts/rca/RcaVerdictCard.spec.ts | 22 +++++++ .../components/alerts/rca/RcaVerdictCard.vue | 11 ++-- .../alerts/rca/RcaWhatHappened.spec.ts | 12 ++++ .../components/alerts/rca/RcaWhatHappened.vue | 3 +- web/src/locales/languages/ar-SA.json | 1 + web/src/locales/languages/de-DE.json | 1 + web/src/locales/languages/en-US.json | 1 + web/src/locales/languages/es-ES.json | 1 + web/src/locales/languages/fr-FR.json | 1 + web/src/locales/languages/it-IT.json | 1 + web/src/locales/languages/ja-JP.json | 1 + web/src/locales/languages/ko-KR.json | 1 + web/src/locales/languages/nl-NL.json | 1 + web/src/locales/languages/pl-PL.json | 1 + web/src/locales/languages/pt-PT.json | 1 + web/src/locales/languages/ru-RU.json | 1 + web/src/locales/languages/tr-TR.json | 1 + web/src/locales/languages/vi-VN.json | 1 + web/src/locales/languages/zh-CN.json | 1 + web/src/locales/languages/zh-TW.json | 1 + web/src/utils/incidents/rcaAskAttachment.ts | 7 ++- web/src/utils/incidents/rcaThread.ts | 17 +++--- 33 files changed, 298 insertions(+), 36 deletions(-) create mode 100644 web/src/components/alerts/rca/RcaInlineText.vue diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 703d340f30..2486d5932f 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -4810,6 +4810,7 @@ "noRun": "276129c87b318d08", "noTranscript": "8d033ae099c5fd89", "pastCauses": "d23f4e22927dd07b", + "resumableNote": "f82b63f5be7d7895", "saved": "b5c120b316c237a0", "showToolData": "1652e947cb43d6be", "skills": "8dc30a7d43a5284a", diff --git a/web/src/components/alerts/IncidentRCAAnalysis.spec.ts b/web/src/components/alerts/IncidentRCAAnalysis.spec.ts index 028ded3383..9b7375cb9e 100644 --- a/web/src/components/alerts/IncidentRCAAnalysis.spec.ts +++ b/web/src/components/alerts/IncidentRCAAnalysis.spec.ts @@ -535,6 +535,17 @@ describe("IncidentRCAAnalysis", () => { expect(byTest(wrapper, "rca-cancelled-message").text()).toBe(cancelledSentence(4, 5)); }); + it("E (m1): with no thread, the run's stored Resume count wins over m - 1", async () => { + answers.thread = null; + answers.threadStatus = "expired"; + const props = cancelledProps(5); + props.rca.summary.reusable_steps = 2; + wrapper = mountPanel(props); + await flushPromises(); + + expect(byTest(wrapper, "rca-cancelled-message").text()).toBe(cancelledSentence(2, 5)); + }); + it("E (m1): with no thread and no steps, 0 of 0", async () => { answers.thread = null; answers.threadStatus = "expired"; @@ -731,19 +742,23 @@ describe("IncidentRCAAnalysis", () => { expect(toastMock).toHaveBeenCalledWith(expect.objectContaining({ variant: "success" })); }); - it("Download as Markdown writes a .md file with the same text", async () => { + it("Download as Markdown writes a .md file with the same text, named for its run", async () => { const createObjectURL = vi.mocked(URL.createObjectURL); createObjectURL.mockClear().mockReturnValueOnce("blob:rca"); vi.mocked(URL.revokeObjectURL).mockClear(); const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); - wrapper = mountPanel(completedProps()); + // The shown run-1 is Run 2: run-0 came before it. + wrapper = mountPanel({ + ...completedProps(), + history: [rcaRunItem(), rcaRunItem({ run_id: "run-0" })], + }); await flushPromises(); await byTest(wrapper, "rca-download-btn").trigger("click"); expect(click).toHaveBeenCalledTimes(1); const anchor = click.mock.contexts[0] as HTMLAnchorElement; - expect(anchor.download).toBe("incident-inc-1-rca.md"); + expect(anchor.download).toBe("incident-inc-1-rca-run-2.md"); const blob = createObjectURL.mock.calls[0][0] as Blob; expect(blob.type).toBe("text/markdown;charset=utf-8"); // jsdom's Blob has no text(); FileReader reads it. @@ -1558,6 +1573,18 @@ describe("IncidentRCAAnalysis", () => { expect(byTest(wrapper, "rca-resume-btn").exists()).toBe(false); }); + it("the thread of a run Resume can continue is not called final; a picked earlier run's is", async () => { + wrapper = mountPanel(failedProps({ resumeRunId: "run-2" })); + await flushPromises(); + expect(byTest(wrapper, "rca-thread-final-note").text()).toBe( + "This run stopped before it finished. Resume run continues from the last finished step.", + ); + + await wrapper.setProps({ viewedRun: rcaRunItem(), threadRunId: "run-1" }); + await flushPromises(); + expect(byTest(wrapper, "rca-thread-final-note").text()).toContain("This report is final"); + }); + it("the resume_run blind spot of the latest run emits resume-rca", async () => { answers.runSummary = rcaRunSummaryCancelled({ state: "failed", diff --git a/web/src/components/alerts/IncidentRCAAnalysis.vue b/web/src/components/alerts/IncidentRCAAnalysis.vue index 14c81308a6..8b66f97ed4 100644 --- a/web/src/components/alerts/IncidentRCAAnalysis.vue +++ b/web/src/components/alerts/IncidentRCAAnalysis.vue @@ -159,7 +159,7 @@ along with this program. If not, see . :blind-spots="recordSummary.blind_spots" :org="orgId" :live="cardState === 'investigating'" - :can-resume="!!resumeRunId && !viewedRun && threadRunId === rca.run_id" + :can-resume="canResumeThreadRun" :resuming="resuming" :triggering="triggering" @resume="resumeLatest" @@ -240,6 +240,7 @@ along with this program. If not, see . :status="threadStatus" :run-live="threadLive" :run-state="threadRunState" + :can-resume="canResumeThreadRun" :trigger-label="threadTriggerLabel" :context-used="threadContextUsed" :run-stats="threadRunStats" @@ -617,8 +618,12 @@ const cancelledText = computed(() => { const current = thread.value?.run_id === runId ? thread.value : null; // The summary can count a step the last thread read did not see yet; it describes the latest run. const total = Math.max(current?.steps.length ?? 0, viewed ? 0 : rca.summary.steps); - // Without a thread, the step that was open at the cancel is the one not counted. - const done = current ? finishedStepCount(current) : Math.max(total - 1, 0); + // Without a thread: the latest run's stored Resume count, else all but the step open at the cancel. + const done = current + ? finishedStepCount(current) + : viewed + ? 0 + : (rca.summary.reusable_steps ?? Math.max(total - 1, 0)); const user = viewed ? viewed.cancelled_by : (currentRun.value?.cancelled_by ?? rca.summary.cancelled_by); @@ -637,6 +642,11 @@ const threadRunState = computed(() => { return findRun(props.threadRunId)?.state ?? null; }); +/** The thread shows the latest run and Resume can continue it. */ +const canResumeThreadRun = computed( + () => !!props.resumeRunId && !props.viewedRun && props.threadRunId === props.rca?.run_id, +); + const threadTriggerLabel = computed(() => { const item = findRun(props.threadRunId); if (item) return rcaTriggerLabel(item.trigger, item.started_by); @@ -768,7 +778,10 @@ const downloadReport = () => { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = `incident-${props.incidentId}-rca.md`; + // The run is in the name, as the selector numbers it, so downloads of different runs do not collide. + const index = props.history.findIndex((run) => run.run_id === cardRunId.value); + const run = index < 0 ? cardRunId.value : `run-${props.history.length - index}`; + link.download = `incident-${props.incidentId}-rca-${run}.md`; document.body.appendChild(link); link.click(); document.body.removeChild(link); diff --git a/web/src/components/alerts/rca/RcaEvidenceCard.spec.ts b/web/src/components/alerts/rca/RcaEvidenceCard.spec.ts index 33d6ac9512..c60d689733 100644 --- a/web/src/components/alerts/rca/RcaEvidenceCard.spec.ts +++ b/web/src/components/alerts/rca/RcaEvidenceCard.spec.ts @@ -84,7 +84,9 @@ describe("RcaEvidenceCard (03 VE23–VE29)", () => { const rootStatement = statementOf(rcaEvidenceEntry().citations[0].hypothesis_id); expect(links[0].text()).not.toBe(rootStatement); expect(links[0].attributes("title")).toBe(rootStatement); - expect(citations[0].text()).toContain(rcaEvidenceEntry().citations[0].observation); + expect(citations[0].text()).toContain( + `Agent's reading: ${rcaEvidenceEntry().citations[0].observation}`, + ); // A ruled-out hypothesis has no role name: the link is its statement. expect(links[1].text()).toBe(statementOf("h4")); // The result summary carries the query time; the card does not add the call's duration again. @@ -244,4 +246,24 @@ describe("RcaEvidenceCard (03 VE23–VE29)", () => { 'click', ); }); + + it("agent inline code in the reading shows as code, never as backticks", () => { + const wrapper = mountCard( + rcaEvidenceEntry({ + citations: [ + { + hypothesis_id: "h1", + evidence_id: "e1", + polarity: "supports", + strength: "decisive", + observation: "`GET /api/data` errors start at the toggle", + }, + ], + }), + ); + + const citation = byTest(wrapper, "rca-evidence-citation"); + expect(citation.text()).not.toContain("`"); + expect(citation.find("code").text()).toBe("GET /api/data"); + }); }); diff --git a/web/src/components/alerts/rca/RcaEvidenceCard.vue b/web/src/components/alerts/rca/RcaEvidenceCard.vue index 14e9b5876e..caa712f156 100644 --- a/web/src/components/alerts/rca/RcaEvidenceCard.vue +++ b/web/src/components/alerts/rca/RcaEvidenceCard.vue @@ -73,14 +73,14 @@ along with this program. If not, see . :title="target.statement" @click="$emit('open-hypothesis', citation.hypothesis_id)" > - {{ target.label }} + {{ t("alerts.incidents.rcaSummary.agentsReading") - }} - {{ citation.observation }} + }}{{ " " }} @@ -132,6 +132,7 @@ import type { } from "@/services/incidents"; import { rcaEvidenceLink, rcaEvidenceQuery } from "@/utils/incidents/rcaEvidenceLinks"; import { rcaEvidenceKindIcon, rcaEvidenceTitle, rcaUtcRange } from "@/utils/incidents/rcaSummary"; +import RcaInlineText from "./RcaInlineText.vue"; import RcaPill from "./RcaPill.vue"; const props = withDefaults( diff --git a/web/src/components/alerts/rca/RcaHypotheses.spec.ts b/web/src/components/alerts/rca/RcaHypotheses.spec.ts index 03ed8ed0a8..ba86efecf5 100644 --- a/web/src/components/alerts/rca/RcaHypotheses.spec.ts +++ b/web/src/components/alerts/rca/RcaHypotheses.spec.ts @@ -91,6 +91,25 @@ describe("RcaHypotheses (03 VE11, VE12, VE18, VE36; F8)", () => { ).toEqual(["DNS", "Node CPU saturation"]); }); + it("agent inline code in a statement or reason shows as code, never as backticks", () => { + const wrapper = mountList( + rcaRunSummary({ + hypotheses: [ + rcaHypothesis({ + statement: "The `adFailure` flag was toggled on", + reason: "`GET /api/data` errors began at the toggle", + }), + ], + }), + ); + + const statement = wrapper.find('[data-test="rca-hypothesis-statement"]'); + expect(statement.text()).toBe("The adFailure flag was toggled on"); + expect(statement.find("code").text()).toBe("adFailure"); + expect(wrapper.text()).not.toContain("`"); + expect(wrapper.findAll("code").map((code) => code.text())).toContain("GET /api/data"); + }); + it("frame C: Not confirmed first, and a root_cause hypothesis reads 'Root cause · not confirmed' (VE18)", () => { const wrapper = mountList(rcaRunSummaryInconclusive()); diff --git a/web/src/components/alerts/rca/RcaHypotheses.vue b/web/src/components/alerts/rca/RcaHypotheses.vue index 36511b26e9..787a34c264 100644 --- a/web/src/components/alerts/rca/RcaHypotheses.vue +++ b/web/src/components/alerts/rca/RcaHypotheses.vue @@ -82,13 +82,13 @@ along with this program. If not, see . hypothesis.status === 'ruled_out' ? 'text-text-secondary' : 'text-text-heading' " > - {{ hypothesis.statement }} +
- {{ hypothesis.reason }} +
@@ -407,6 +412,7 @@ import type { RcaRunStats, RcaSteerRecord, RcaSteering, + RcaStep, RcaThread, RcaThreadQuestion, } from "@/services/incidents"; @@ -415,6 +421,7 @@ import type { RcaSteerSendResult } from "@/composables/useRcaSteering"; import { formatRcaClock, formatRcaDuration, + isFinishedStep, rcaThreadEntries, type RcaThreadEntry, } from "@/utils/incidents/rcaThread"; @@ -435,8 +442,10 @@ const props = withDefaults( status: RcaReadStatus; /** The thread's run is running or waiting for input. */ runLive: boolean; - /** State of the thread's run; a run that is not live gets the final note. */ + /** State of the thread's run; a run that is not live gets the closing note. */ runState: RcaRunState | null; + /** Resume can continue the thread's run, so its note is not final. */ + canResume?: boolean; /** "Auto on incident open", "Re-analyze by j.okafor", … */ triggerLabel?: I18nText | null; /** `rca_runs.context_used` of the thread's run, when known. */ @@ -460,6 +469,7 @@ const props = withDefaults( send?: ((text: string) => Promise) | null; }>(), { + canResume: false, triggerLabel: null, contextUsed: null, runStats: null, @@ -563,6 +573,15 @@ const entries = computed(() => props.thread ? rcaThreadEntries(props.thread, props.steering?.items ?? []) : [], ); +/** A cancelled or failed run's steps that a Resume repeats; a completed run repeats none. */ +const unfinishedSteps = computed(() => { + const thread = props.thread; + if (!thread || (props.runState !== "cancelled" && props.runState !== "failed")) { + return new Set(); + } + return new Set(thread.steps.filter((step, idx) => !isFinishedStep(thread, step, idx))); +}); + const STEER_PILL_VARIANT: Record = { queued: "default-outline", delivered: "default-outline", diff --git a/web/src/components/alerts/rca/RcaThreadStep.vue b/web/src/components/alerts/rca/RcaThreadStep.vue index 66081b37d3..c5b87c0145 100644 --- a/web/src/components/alerts/rca/RcaThreadStep.vue +++ b/web/src/components/alerts/rca/RcaThreadStep.vue @@ -44,7 +44,7 @@ along with this program. If not, see .