Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions src/sentry/seer/agent/embed_widgets.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@
},
{
"name": "autofix",
"description": "Render one step of a Seer Autofix run (root cause, solution, or code changes) as a collapsible block linking back to the issue. Emit this embed whenever the user signals intent to fix, solve, debug, or resolve a problem — e.g. \"fix this issue\", \"solve the problem\", \"find the root cause\", \"why is this happening\", \"how do I resolve this error\" — or asks for the status/result of an autofix run already in progress. `id` and `shortId` are the issue the run belongs to, exactly as the issue API returns them. `step` is the autofix step identifier exactly as the autofix API reports it — the UI renders the human-readable label, so do not send a display string. `result` is the full markdown write-up for that step. Prefer this embed over a plaintext explanation whenever an issue can be autofixed, and emit one embed per step rather than combining multiple steps into one.",
"description": "Render one step of a Seer Autofix run (root cause, solution, or code changes) as a collapsible block linking back to the issue. Emit this embed whenever the user signals intent to fix, solve, debug, or resolve a problem — e.g. \"fix this issue\", \"solve the problem\", \"find the root cause\", \"why is this happening\", \"how do I resolve this error\" — or asks for the status/result of an autofix run already in progress. `id` and `shortId` are the issue the run belongs to, exactly as the issue API returns them. `step` is the autofix step identifier exactly as the autofix API reports it — the UI renders the human-readable label, so do not send a display string. `result` is the markdown summary for that step. Send the step's detail in the structured fields rather than folding it into `result`, so it renders as the same sections a live run shows: `fiveWhys` and `reproductionSteps` for `root_cause`, `steps` for `solution`. Prefer this embed over a plaintext explanation whenever an issue can be autofixed, and emit one embed per step rather than combining multiple steps into one.",
"level": ["block"],
"body": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
Expand All @@ -470,6 +470,37 @@
},
"shortId": {
"type": "string"
},
"fiveWhys": {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there actually always 5 whys

"description": "root_cause only: the causal chain, most immediate cause first.",
"type": "array",
"items": {
"type": "string"
}
},
"reproductionSteps": {
"description": "root_cause only: ordered steps that reproduce the error.",
"type": "array",
"items": {
"type": "string"
}
},
"steps": {
"description": "solution only: the ordered steps needed to resolve the issue.",
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["title", "description"],
"additionalProperties": false
}
}
},
"required": ["step", "result", "id", "shortId"],
Expand All @@ -481,9 +512,41 @@
"data": {
"id": "1234567890",
"shortId": "EXMPL-123",
"result": "The root cause of the issue is that the code is not working correctly.",
"result": "`CartService.total()` reduces the line items without an initial accumulator, so an empty cart throws instead of totalling to zero.",
"fiveWhys": [
"`POST /checkout` returned a 500 for every request with an empty cart.",
"`CartService.total()` threw `TypeError: Reduce of empty array with no initial value`.",
"`items.reduce((sum, item) => sum + item.price)` was called without a second argument.",
"With no initial value `reduce` uses the first element as the seed, which an empty array does not have.",
"The empty cart path was never covered — every test seeded at least one line item."
],
"reproductionSteps": [
"Sign in and add a single item to the cart.",
"Remove that item, leaving the cart empty.",
"Open `/checkout`, which calls `POST /api/checkout/quote`.",
"The request 500s and the page renders the generic error state."
],
"step": "root_cause"
}
},
{
"label": "Plan",
"data": {
"id": "1234567890",
"shortId": "EXMPL-123",
"result": "Seed the reduction with `0` so an empty cart totals to zero, and cover the path with a test.",
"steps": [
{
"title": "Pass an initial accumulator to `CartService.total()`",
"description": "Change `items.reduce((sum, item) => sum + item.price)` to pass `0` as the second argument."
},
{
"title": "Add a regression test for the empty cart",
"description": "Assert `total()` returns `0` for `[]` in `src/checkout/cartService.test.ts`."
}
],
"step": "solution"
}
}
],
"featureFlag": "organizations:seer-agent-autofix"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function isRootCauseArtifact(
);
}

interface SolutionStep {
export interface SolutionStep {
description: string;
title: string;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {useInfiniteQuery} from '@tanstack/react-query';
import {OrganizationFixture} from 'sentry-fixture/organization';

import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import type {ExplorerAutofixState} from 'sentry/components/events/autofix/useExplorerAutofix';
import {SeerMarkdown} from 'sentry/components/seer/markdown';
import {AutofixRef} from 'sentry/components/seer/markdown/embeds/components/autofix';
import type {Group} from 'sentry/types/group';
import {apiOptions} from 'sentry/utils/api/apiOptions';
Expand Down Expand Up @@ -209,3 +210,78 @@ describe('AutofixRef embed', () => {
await waitFor(() => expect(issuesMock).toHaveBeenCalledTimes(3));
}, 20_000);
});

const ISSUE = {id: '6789012345', shortId: 'CHECKOUT-42'};

function renderAutofixEmbed(data: Record<string, unknown>) {
const tag = `{% autofix %}${JSON.stringify({...ISSUE, ...data})}{% /autofix %}`;
return render(<SeerMarkdown raw={tag} />);
}

async function expand(name: string) {
await userEvent.click(screen.getByRole('button', {name: new RegExp(name)}));
}

describe('autofix embed', () => {
it('renders the root cause sections a live run shows', async () => {
renderAutofixEmbed({
step: 'root_cause',
result: '`CartService.total()` reduces line items without an initial accumulator.',
fiveWhys: [
'`POST /api/checkout/quote` returned a 500 for every empty cart.',
'The empty-cart path was never exercised by a test.',
],
reproductionSteps: ['Empty the cart.', 'Open `/checkout`.'],
});

await expand('Root Cause');

expect(
screen.getByText(/reduces line items without an initial accumulator/)
).toBeInTheDocument();

expect(screen.getByText('Why did this happen?')).toBeInTheDocument();
expect(screen.getByText(/returned a 500 for every empty cart/)).toBeInTheDocument();

expect(screen.getByText('Reproduction Steps')).toBeInTheDocument();
expect(screen.getByText('Empty the cart.')).toBeInTheDocument();
});

it('renders the plan steps', async () => {
renderAutofixEmbed({
step: 'solution',
result: 'Seed the reduction with `0`.',
steps: [
{
title: 'Pass an initial accumulator',
description: 'Pass `0` as the second argument to `reduce`.',
},
],
});

await expand('Plan');

expect(screen.getByText('Steps to Resolve')).toBeInTheDocument();
expect(screen.getByText('Pass an initial accumulator')).toBeInTheDocument();
expect(
screen.getByText('Pass `0` as the second argument to `reduce`.')
).toBeInTheDocument();
});

// Seer writes this embed itself, so the structured fields can be absent even
// on a step that normally carries them.
it('renders the summary alone when no structured detail is sent', async () => {
renderAutofixEmbed({
step: 'root_cause',
result: 'The cart total throws on an empty cart.',
});

await expand('Root Cause');

expect(
screen.getByText('The cart total throws on an empty cart.')
).toBeInTheDocument();
expect(screen.queryByText('Why did this happen?')).not.toBeInTheDocument();
expect(screen.queryByText('Reproduction Steps')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
NEXT_STEP,
STEP_LABELS,
} from 'sentry/components/seer/markdown/embeds/components/autofix';
import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils';
import {IconArrow} from 'sentry/icons';
import * as Storybook from 'sentry/stories';
import type {Group} from 'sentry/types/group';
Expand All @@ -34,8 +35,21 @@ import type {

const ISSUE = {id: '6789012345', shortId: 'CHECKOUT-42'};

function autofix(step: AutofixExplorerStep, result: string): string {
return `{% autofix %}${JSON.stringify({...ISSUE, step, result})}{% /autofix %}`;
/**
* Taken from the embed schema so these fixtures fail to compile rather than
* silently drop a field if the structured payload changes shape.
*/
type AutofixDetails = Pick<
EmbedOutput<'autofix'>,
'fiveWhys' | 'reproductionSteps' | 'steps'
>;

function autofix(
step: AutofixExplorerStep,
result: string,
details: AutofixDetails = {}
): string {
return `{% autofix %}${JSON.stringify({...ISSUE, step, result, ...details})}{% /autofix %}`;
}

function autofixRefTag(
Expand All @@ -48,23 +62,69 @@ function autofixRefTag(

const ROOT_CAUSE = autofix(
'root_cause',
'`CartService.total()` calls `items.reduce((sum, item) => sum + item.price)` without an initial accumulator. When a customer empties their cart the array is empty, so `reduce` throws `TypeError: Reduce of empty array with no initial value` and the checkout request 500s.'
'`CartService.total()` reduces the line items without an initial accumulator, so an empty cart throws `TypeError: Reduce of empty array with no initial value` and `POST /api/checkout/quote` 500s.',
{
fiveWhys: [
'`POST /api/checkout/quote` returned a 500 for every request carrying an empty cart.',
'`CartService.total()` threw `TypeError: Reduce of empty array with no initial value`.',
'`items.reduce((sum, item) => sum + item.price)` is called without a second argument.',
'Without an initial value `reduce` seeds itself from the first element, which an empty array does not have.',
'The empty-cart path was never exercised — every fixture in `cartService.test.ts` seeds at least one line item.',
],
reproductionSteps: [
'Sign in as any customer and add one item to the cart.',
'Remove that item, leaving the cart empty.',
'Navigate to `/checkout`, which calls `POST /api/checkout/quote` on mount.',
'The request 500s and the page falls back to the generic error state.',
],
}
);

const SOLUTION = autofix(
'solution',
'Seed the reduction with `0` so an empty cart totals to zero instead of throwing: `items.reduce((sum, item) => sum + item.price, 0)`.'
'Seed the reduction with `0` so an empty cart totals to zero instead of throwing.',
{
steps: [
{
title: 'Pass an initial accumulator to `CartService.total()`',
description:
'Change `items.reduce((sum, item) => sum + item.price)` to pass `0` as the second argument.',
},
{
title: 'Cover the empty cart in `cartService.test.ts`',
description: 'Assert `total()` returns `0` for an empty line-item array.',
},
],
}
);

const CODE_CHANGES = autofix(
'code_changes',
'Updated `src/checkout/cartService.ts` to pass the initial value and added a regression test covering the empty-cart path.'
'2 files changed in 1 repo — `src/checkout/cartService.ts` now passes the initial value, and `src/checkout/cartService.test.ts` covers the empty-cart path.'
);

// Autofix has no "plan" step — a plan is the write-up of the solution step.
const PLANNED_SOLUTION = autofix(
'solution',
'Guard `CartService.total()` with an initial accumulator of `0`, add a regression test covering the empty-cart path, then backfill a smoke test that renders the checkout page with zero items.'
'Guard `CartService.total()` against an empty cart, then close the coverage gap that let this ship.',
{
steps: [
{
title: 'Pass an initial accumulator to `CartService.total()`',
description:
'Change `items.reduce((sum, item) => sum + item.price)` to pass `0` as the second argument.',
},
{
title: 'Cover the empty cart in `cartService.test.ts`',
description: 'Assert `total()` returns `0` for an empty line-item array.',
},
{
title: 'Add a checkout smoke test with zero items',
description:
'Render `/checkout` with an empty cart and assert the quote renders `$0.00` instead of the error state.',
},
],
}
);

function User({children}: {children: ReactNode}) {
Expand Down
Loading
Loading