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
186 changes: 138 additions & 48 deletions static/app/components/events/autofix/useExplorerAutofix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import useOrganization from 'sentry/utils/useOrganization';
import {useUser} from 'sentry/utils/useUser';
import {
isArtifact,
isExplorerFilePatch,
isRepoPRState,
type Artifact,
type Block,
type ExplorerCodingAgentState,
Expand Down Expand Up @@ -131,6 +133,14 @@ export interface TriageArtifact {
suspect_commit?: SuspectCommit | null;
}

export function isCodeChangesArtifact(value: unknown): value is ExplorerFilePatch[] {
return isArrayOf(value, isExplorerFilePatch) && value.length > 0;
}

export function isPullRequestArtifact(value: unknown): value is RepoPRState[] {
return isArrayOf(value, isRepoPRState) && value.length > 0;
}

/**
* State returned from the Explorer autofix endpoint.
* This extends the SeerExplorer types with autofix-specific data.
Expand Down Expand Up @@ -290,78 +300,158 @@ export function getOrderedArtifactKeys(
});
}

const CODE_CHANGES_KEY = Symbol('codeChanges');

type ArtifactKey = string | typeof CODE_CHANGES_KEY;
export type AutofixArtifact = Artifact<unknown> | ExplorerFilePatch[] | RepoPRState[];
export interface AutofixSection {
artifacts: AutofixArtifact[];
messages: Array<Block['message']>;
status: 'processing' | 'completed';
step: string;
}

export function getOrderedAutofixArtifacts(
runState: ExplorerAutofixState | null
): AutofixArtifact[] {
/**
* Groups a flat list of autofix blocks into ordered sections.
*
* Blocks arrive as a flat stream from the backend. Each block may carry a
* `metadata.step` field that signals the start of a new logical section
* (e.g. "root_cause", "code_changes", "pull_request"). This function walks
* the blocks in order, splitting them into sections at each step boundary,
* and attaches the relevant artifacts (file patches, PR states) to each section.
*/
export function getOrderedAutofixSections(runState: ExplorerAutofixState | null) {
Copy link
Member

Choose a reason for hiding this comment

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

do you mind adding some comments on the operations being done in this function? I found the imperative style of updating section and pushing to sections a little confusing to read, though makes sense now

const blocks = runState?.blocks ?? [];
if (!blocks.length) {
return [];
}

type OrderedArtifact = {
artifact: Artifact;
index: number;
type: 'artifact';
};
// Accumulates file patches across all blocks, keyed by "repo:path".
// Patches are merged globally (later patches for the same file overwrite
// earlier ones) and snapshotted into the code_changes section when it finalizes.
const mergedByFile = new Map<string, ExplorerFilePatch>();

const sections: AutofixSection[] = [];

type OrderedExplorerFilePatch = {
index: number;
patches: Map<string, ExplorerFilePatch>;
type: 'patch';
// The "current" section being built. Blocks without a step marker are
// appended to whatever section is in progress (initially an 'unknown' one).
let section: AutofixSection = {
step: 'unknown',
artifacts: [],
messages: [],
status: 'processing',
};

const artifactsByKey = new Map<
ArtifactKey,
OrderedArtifact | OrderedExplorerFilePatch
>();
const mergedByFile = new Map<string, ExplorerFilePatch>();
// Closes the current section and pushes it to `sections` (if non-empty).
function finalizeSection() {
Copy link
Member

Choose a reason for hiding this comment

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

can we add a comment on what this fx does

if (section.messages.length) {
// Mark the section as completed if the last message is a terminal marker.
const lastMessage = section.messages[section.messages.length - 1];
if (isLastMessageOfSection(lastMessage)) {
section.status = 'completed';
}

for (let index = 0; index < blocks.length; index++) {
const block = blocks[index]!;
if (section.step === 'code_changes') {
// Snapshot the accumulated file patches as an artifact for this section.
section.artifacts.push(Array.from(mergedByFile.values()));
}

for (const artifact of block.artifacts ?? []) {
artifactsByKey.set(artifact.key, {
type: 'artifact',
index,
artifact,
});
sections.push(section);
}
}

for (const block of blocks) {
// Accumulate file patches globally — they need to be merged across all
// blocks regardless of section boundaries so later patches win per file.
if (block.merged_file_patches?.length) {
for (const patch of block.merged_file_patches) {
const key = `${patch.repo_name}:${patch.patch.path}`;
mergedByFile.set(key, patch);
}
artifactsByKey.set(CODE_CHANGES_KEY, {
type: 'patch',
index,
patches: mergedByFile,
});
}
}

const artifacts: AutofixArtifact[] = [...artifactsByKey.values()]
.sort((artifact1, artifact2) => artifact1.index - artifact2.index)
.map(artifact => {
if (artifact.type === 'artifact') {
return artifact.artifact;
}
return Array.from(artifact.patches.values());
});
const message = block.message;

// A step marker means this block starts a new section.
// Finalize the previous section and start a fresh one.
const metadata = message.metadata;
if (metadata?.step) {
finalizeSection();

if (runState?.repo_pr_states) {
const states = Object.values(runState.repo_pr_states);
if (states.length) {
artifacts.push(states);
section = {
step: metadata.step,
artifacts: [],
messages: [],
status: 'processing',
};
}

// Append the block's message and any inline artifacts to the current section.
section.messages.push(message);
section.artifacts.push(...(block.artifacts ?? []));
Copy link
Contributor

Choose a reason for hiding this comment

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

Patches collected before prior section is finalized

Low Severity

In the loop inside getOrderedAutofixSections, each block's merged_file_patches are added to mergedByFile before finalizeSection() is called for the previous section. If a block that starts a new section also carries merged_file_patches, those patches leak into the previous code_changes section's artifact snapshot. The patch collection (lines 346–351) needs to happen after the finalization check (lines 356–365) to ensure the outgoing section receives only patches from its own blocks.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

This is intentional

}

return artifacts;
// Finalize the last in-progress section.
finalizeSection();

// If there are any PR states, append a synthetic "pull_request" section.
const artifact = Object.values(runState?.repo_pr_states ?? {});
if (artifact.length) {
sections.push({
step: 'pull_request',
artifacts: [artifact],
messages: [],
status: artifact.some(pullRequest => pullRequest.pr_creation_status === 'creating')
? 'processing'
: 'completed',
});
}

return sections;
}

export function isRootCauseSection(section: AutofixSection): boolean {
return section.step === 'root_cause';
}

export function isSolutionSection(section: AutofixSection): boolean {
return section.step === 'solution';
}

export function isCodeChangesSection(section: AutofixSection): boolean {
return section.step === 'code_changes';
}

export function isPullRequestSection(section: AutofixSection): boolean {
return section.step === 'pull_request';
}

export type AutofixArtifact = Artifact<unknown> | ExplorerFilePatch[] | RepoPRState[];

export function getOrderedAutofixArtifacts(
runState: ExplorerAutofixState | null
): AutofixArtifact[] {
return getOrderedAutofixSections(runState)
.map(section => {
if (isRootCauseSection(section)) {
return section.artifacts.findLast(isRootCauseArtifact) ?? null;
}
if (isSolutionSection(section)) {
return section.artifacts.findLast(isSolutionArtifact) ?? null;
}
if (isCodeChangesSection(section)) {
return section.artifacts.findLast(isCodeChangesArtifact) ?? null;
}
if (isPullRequestSection(section)) {
return section.artifacts.findLast(isPullRequestArtifact) ?? null;
}
return null;
})
.filter(defined);
}

function isLastMessageOfSection(message?: Block['message']): boolean {
return (
message?.role === 'assistant' &&
message?.content !== 'Thinking...' &&
!message?.tool_calls?.length
);
}

/**
Expand Down
Loading
Loading