Skip to content
Draft
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
66 changes: 66 additions & 0 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
TagListSchema,
ApiErrorSchema,
ClientKeyListSchema,
type AutofixExplorerStepSchema,
AutofixExplorerRunStateSchema,
AutofixRunSchema,
AutofixRunStateSchema,
TraceMetaSchema,
Expand All @@ -59,6 +61,7 @@ import { createApiError, ApiNotFoundError, ApiValidationError } from "./errors";
import { USER_AGENT } from "../version";
import type { SentryProtocol } from "../types";
import type {
AutofixExplorerRunState,
AutofixRun,
AutofixRunState,
ClientKey,
Expand Down Expand Up @@ -2614,6 +2617,69 @@ export class SentryApiService {
return AutofixRunStateSchema.parse(body);
}

// POST https://us.sentry.io/api/0/issues/5485083130/autofix/?mode=explorer
//
// Explorer mode advances the run one logical step at a time. A new run is
// created when `runId` is omitted (allowed only for `step: "root_cause"`);
// later steps must reuse the original run via `runId`.
async startAutofixExplorer(
{
organizationSlug,
issueId,
step,
runId,
userContext,
insertIndex,
}: {
organizationSlug: string;
issueId: string;
step: z.infer<typeof AutofixExplorerStepSchema>;
runId?: number;
userContext?: string;
insertIndex?: number;
},
opts?: RequestOptions,
): Promise<AutofixRun> {
const payload: Record<string, unknown> = { step };
if (runId !== undefined) {
payload.run_id = runId;
}
if (userContext !== undefined && userContext !== "") {
payload.user_context = userContext;
}
if (insertIndex !== undefined) {
payload.insert_index = insertIndex;
}
const body = await this.requestJSON(
`/organizations/${organizationSlug}/issues/${issueId}/autofix/?mode=explorer`,
{
method: "POST",
body: JSON.stringify(payload),
},
opts,
);
return AutofixRunSchema.parse(body);
}

// GET https://us.sentry.io/api/0/issues/5485083130/autofix/?mode=explorer
async getAutofixExplorerState(
{
organizationSlug,
issueId,
}: {
organizationSlug: string;
issueId: string;
},
opts?: RequestOptions,
): Promise<AutofixExplorerRunState> {
const body = await this.requestJSON(
`/organizations/${organizationSlug}/issues/${issueId}/autofix/?mode=explorer`,
undefined,
opts,
);
return AutofixExplorerRunStateSchema.parse(body);
}

/**
* Retrieves high-level metadata about a trace.
*
Expand Down
40 changes: 24 additions & 16 deletions packages/mcp-core/src/api-client/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
} from "@sentry/mcp-server-mocks";
import { describe, expect, it } from "vitest";
import {
AutofixExplorerRunStateSchema,
AutofixRunSchema,
AutofixRunStateSchema,
ClientKeySchema,
EventSchema,
FlamegraphSchema,
Expand Down Expand Up @@ -614,23 +614,31 @@ describe("AutofixRunSchema", () => {
});
});

describe("AutofixRunStateSchema", () => {
it("accepts explorer-style autofix state without legacy steps", () => {
const state = AutofixRunStateSchema.parse(autofixStateExplorerFixture);
describe("AutofixExplorerRunStateSchema", () => {
it("parses the explorer fixture with typed blocks and artifacts", () => {
const state = AutofixExplorerRunStateSchema.parse(
autofixStateExplorerFixture,
);

expect(state.autofix?.steps).toEqual([]);
expect(state.autofix?.blocks).toEqual([
{
type: "root_cause",
title: "Investigate failing request",
status: "COMPLETED",
},
{
type: "solution",
title: "Draft fix plan",
status: "IN_PROGRESS",
expect(state.autofix?.status).toBe("completed");
expect(state.autofix?.run_id).toBe(21831);

const blocks = state.autofix?.blocks ?? [];
expect(blocks).toHaveLength(2);
expect(blocks[0]?.message.metadata?.step).toBe("root_cause");
expect(blocks[1]?.message.metadata?.step).toBe("solution");
expect(blocks[0]?.artifacts?.[0]?.key).toBe("root_cause");
});

it("defaults missing blocks arrays to []", () => {
const state = AutofixExplorerRunStateSchema.parse({
autofix: {
run_id: 1,
status: "processing",
},
]);
});

expect(state.autofix?.blocks).toEqual([]);
});
});

Expand Down
156 changes: 156 additions & 0 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,162 @@ export const AutofixRunStateSchema = z.object({
.nullable(),
});

/**
* Status values emitted by the explorer-mode autofix run. Upstream type:
* `static/app/components/events/autofix/useExplorerAutofix.tsx`.
*/
export const AutofixExplorerStatusSchema = z.enum([
"processing",
"completed",
"error",
"awaiting_user_input",
]);

/**
* Steps the client can ask the autofix run to advance through. A new run
* starts with `root_cause`; later steps must reuse the original `run_id`.
*/
export const AutofixExplorerStepSchema = z.enum([
"root_cause",
"solution",
"code_changes",
]);

/**
* Diff metadata for a single file in a code-changes block. The upstream
* `patch` object carries extra fields (hunks, source/target paths); only the
* fields we surface are spelled out, the rest stay in passthrough.
*/
export const AutofixExplorerFilePatchSchema = z
.object({
repo_name: z.string(),
diff: z.string().optional(),
patch: z
.object({
path: z.string(),
added: z.number().optional(),
removed: z.number().optional(),
})
.passthrough(),
})
.passthrough();

/**
* Inline artifact attached to a block. The shape of `data` depends on the
* artifact `key` (e.g. `root_cause`, `solution`), so it's `unknown` here and
* narrowed at the call site.
*/
export const AutofixExplorerArtifactSchema = z
.object({
key: z.string(),
data: z.unknown().nullable(),
reason: z.string().optional(),
})
.passthrough();

/**
* One block in the explorer-mode autofix stream. Blocks arrive in order; a
* block with `message.metadata.step` set starts a new section
* (root_cause / solution / code_changes / etc.).
*/
export const AutofixExplorerBlockSchema = z
.object({
id: z.string(),
timestamp: z.string().optional(),
loading: z.boolean().optional(),
message: z
.object({
role: z.string(),
content: z.string(),
metadata: z
.object({
step: z.string().optional(),
})
.passthrough()
.nullable()
.optional(),
tool_calls: z.array(z.unknown()).optional(),
thinking_content: z.string().optional(),
})
.passthrough(),
artifacts: z.array(AutofixExplorerArtifactSchema).optional(),
merged_file_patches: z.array(AutofixExplorerFilePatchSchema).optional(),
file_patches: z.array(AutofixExplorerFilePatchSchema).optional(),
})
.passthrough();

/**
* PR creation state, one entry per repo. Most fields can be null while a PR
* is being created.
*/
export const AutofixExplorerRepoPRStateSchema = z
.object({
repo_name: z.string(),
branch_name: z.string().nullable().optional(),
commit_sha: z.string().nullable().optional(),
pr_creation_error: z.string().nullable().optional(),
pr_creation_status: z
.enum(["creating", "completed", "error"])
.nullable()
.optional(),
pr_id: z.number().nullable().optional(),
pr_number: z.number().nullable().optional(),
pr_url: z.string().nullable().optional(),
title: z.string().nullable().optional(),
})
.passthrough();

export const AutofixExplorerCodingAgentStateSchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
provider: z.string().optional(),
started_at: z.string().optional(),
status: z.string().optional(),
agent_url: z.string().optional(),
})
.passthrough();

/**
* Full response from the explorer-mode autofix GET endpoint
* (`/organizations/{org}/issues/{id}/autofix/?mode=explorer`). The endpoint is
* still marked experimental upstream, so passthroughs are used to stay
* resilient to additive changes.
*
* Upstream source of truth in getsentry/sentry:
* - `src/sentry/seer/endpoints/group_ai_autofix.py`
* - `static/app/components/events/autofix/useExplorerAutofix.tsx`
*/
export const AutofixExplorerRunStateSchema = z.object({
autofix: z
.object({
run_id: z.number(),
status: AutofixExplorerStatusSchema,
updated_at: z.string().nullable().optional(),
blocks: z.preprocess(
(value) => value ?? [],
z.array(AutofixExplorerBlockSchema),
),
pending_user_input: z
.object({
id: z.string(),
input_type: z.string(),
data: z.record(z.string(), z.unknown()).optional(),
})
.passthrough()
.nullable()
.optional(),
repo_pr_states: z
.record(z.string(), AutofixExplorerRepoPRStateSchema)
.optional(),
coding_agents: z
.record(z.string(), AutofixExplorerCodingAgentStateSchema)
.optional(),
})
.passthrough()
.nullable(),
});

export const EventAttachmentSchema = z.object({
id: z.string(),
name: z.string(),
Expand Down
26 changes: 26 additions & 0 deletions packages/mcp-core/src/api-client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@
import type { z } from "zod";
import type {
AssignedToSchema,
AutofixExplorerArtifactSchema,
AutofixExplorerBlockSchema,
AutofixExplorerCodingAgentStateSchema,
AutofixExplorerFilePatchSchema,
AutofixExplorerRepoPRStateSchema,
AutofixExplorerRunStateSchema,
AutofixExplorerStatusSchema,
AutofixExplorerStepSchema,
AutofixRunSchema,
AutofixRunStateSchema,
ClientKeyListSchema,
Expand Down Expand Up @@ -117,6 +125,24 @@ export type EventAttachment = z.infer<typeof EventAttachmentSchema>;
export type Tag = z.infer<typeof TagSchema>;
export type AutofixRun = z.infer<typeof AutofixRunSchema>;
export type AutofixRunState = z.infer<typeof AutofixRunStateSchema>;
export type AutofixExplorerStatus = z.infer<typeof AutofixExplorerStatusSchema>;
export type AutofixExplorerStep = z.infer<typeof AutofixExplorerStepSchema>;
export type AutofixExplorerBlock = z.infer<typeof AutofixExplorerBlockSchema>;
export type AutofixExplorerArtifact = z.infer<
typeof AutofixExplorerArtifactSchema
>;
export type AutofixExplorerFilePatch = z.infer<
typeof AutofixExplorerFilePatchSchema
>;
export type AutofixExplorerRepoPRState = z.infer<
typeof AutofixExplorerRepoPRStateSchema
>;
export type AutofixExplorerCodingAgentState = z.infer<
typeof AutofixExplorerCodingAgentStateSchema
>;
export type AutofixExplorerRunState = z.infer<
typeof AutofixExplorerRunStateSchema
>;
export type AssignedTo = z.infer<typeof AssignedToSchema>;
export type ReplayDetails = z.infer<typeof ReplayDetailsSchema>;
export type ReplayList = z.infer<typeof ReplayListResponseSchema>["data"];
Expand Down
Loading
Loading