diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 4a64718c2..51b79c3ee 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -40,6 +40,8 @@ import { TagListSchema, ApiErrorSchema, ClientKeyListSchema, + type AutofixExplorerStepSchema, + AutofixExplorerRunStateSchema, AutofixRunSchema, AutofixRunStateSchema, TraceMetaSchema, @@ -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, @@ -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; + runId?: number; + userContext?: string; + insertIndex?: number; + }, + opts?: RequestOptions, + ): Promise { + const payload: Record = { 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 { + 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. * diff --git a/packages/mcp-core/src/api-client/schema.test.ts b/packages/mcp-core/src/api-client/schema.test.ts index 7549a1e97..fc439bcdd 100644 --- a/packages/mcp-core/src/api-client/schema.test.ts +++ b/packages/mcp-core/src/api-client/schema.test.ts @@ -8,8 +8,8 @@ import { } from "@sentry/mcp-server-mocks"; import { describe, expect, it } from "vitest"; import { + AutofixExplorerRunStateSchema, AutofixRunSchema, - AutofixRunStateSchema, ClientKeySchema, EventSchema, FlamegraphSchema, @@ -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([]); }); }); diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index e7dfe321c..29f525027 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -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(), diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 23fee44f0..0e85b1175 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -41,6 +41,14 @@ import type { z } from "zod"; import type { AssignedToSchema, + AutofixExplorerArtifactSchema, + AutofixExplorerBlockSchema, + AutofixExplorerCodingAgentStateSchema, + AutofixExplorerFilePatchSchema, + AutofixExplorerRepoPRStateSchema, + AutofixExplorerRunStateSchema, + AutofixExplorerStatusSchema, + AutofixExplorerStepSchema, AutofixRunSchema, AutofixRunStateSchema, ClientKeyListSchema, @@ -117,6 +125,24 @@ export type EventAttachment = z.infer; export type Tag = z.infer; export type AutofixRun = z.infer; export type AutofixRunState = z.infer; +export type AutofixExplorerStatus = z.infer; +export type AutofixExplorerStep = z.infer; +export type AutofixExplorerBlock = z.infer; +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; export type ReplayDetails = z.infer; export type ReplayList = z.infer["data"]; diff --git a/packages/mcp-server-mocks/src/fixtures/autofix-state-explorer.json b/packages/mcp-server-mocks/src/fixtures/autofix-state-explorer.json index 040a20c2e..7cbf8a481 100644 --- a/packages/mcp-server-mocks/src/fixtures/autofix-state-explorer.json +++ b/packages/mcp-server-mocks/src/fixtures/autofix-state-explorer.json @@ -1,20 +1,61 @@ { "autofix": { "run_id": 21831, - "status": "IN_PROGRESS", + "status": "completed", + "updated_at": "2026-04-22T12:00:00Z", "blocks": [ { - "type": "root_cause", - "title": "Investigate failing request", - "status": "COMPLETED" + "id": "block-1", + "timestamp": "2026-04-22T11:55:00Z", + "message": { + "role": "assistant", + "content": "Looking at the failing request to figure out why it errored.", + "metadata": { "step": "root_cause" } + }, + "artifacts": [ + { + "key": "root_cause", + "reason": "Found the failing handler.", + "data": { + "one_line_description": "The failing request hit an unguarded null in the cache layer.", + "five_whys": [ + "The cache returned null.", + "The fallback path threw." + ], + "reproduction_steps": ["Send a cache miss request to `/v1/lookup`."] + } + } + ] }, { - "type": "solution", - "title": "Draft fix plan", - "status": "IN_PROGRESS" + "id": "block-2", + "timestamp": "2026-04-22T11:59:00Z", + "message": { + "role": "assistant", + "content": "Proposing fix plan.", + "metadata": { "step": "solution" } + }, + "artifacts": [ + { + "key": "solution", + "reason": "Plan ready for review.", + "data": { + "one_line_summary": "Guard the cache fallback path against null values.", + "steps": [ + { + "title": "Wrap cache fallback in a null guard", + "description": "Return an empty payload instead of throwing when the cache misses." + }, + { + "title": "Add a regression test", + "description": "Exercise the null path so a future regression is caught." + } + ] + } + } + ] } ], - "updated_at": "2026-04-22T12:00:00Z", "pending_user_input": null, "repo_pr_states": {}, "coding_agents": {}