diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index cc99ed1b0..563c68cae 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -143,7 +143,6 @@ sentry issue plan 123456789 --cause 0 Generate a solution plan using Seer AI **Flags:** -- `--cause - Root cause ID to plan (required if multiple causes exist)` - `--force - Force new plan even if one exists` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index df3b1136c..7ce8959f6 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -7,8 +7,8 @@ import type { SentryContext } from "../../context.js"; import { triggerSolutionPlanning } from "../../lib/api-client.js"; -import { buildCommand, numberParser } from "../../lib/command.js"; -import { ApiError, ValidationError } from "../../lib/errors.js"; +import { buildCommand } from "../../lib/command.js"; +import { ApiError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { formatSolution, @@ -26,7 +26,6 @@ import { extractNoSolutionReason, extractRootCauses, extractSolution, - type RootCause, type SolutionArtifact, } from "../../types/seer.js"; import { @@ -37,78 +36,12 @@ import { } from "./utils.js"; type PlanFlags = { - readonly cause?: number; readonly json: boolean; readonly force: boolean; readonly fresh: boolean; readonly fields?: string[]; }; -/** - * Validate that the autofix state has root causes identified. - * - * @param state - Current autofix state (already ensured to exist) - * @returns Array of root causes - * @throws {ValidationError} If no root causes found - */ -function validateRootCauses(state: AutofixState): RootCause[] { - const causes = extractRootCauses(state); - if (causes.length === 0) { - throw new ValidationError( - "No root causes identified. Cannot create a plan without a root cause." - ); - } - return causes; -} - -/** - * Validate and resolve the cause selection for solution planning. - * - * @param causes - Array of available root causes - * @param selectedCause - User-specified cause index, or undefined for auto-select - * @param issueId - Issue ID for error message hints - * @returns Validated cause index (0-based) - * @throws {ValidationError} If multiple causes exist without selection, or if selection is out of range - */ -function validateCauseSelection( - causes: RootCause[], - selectedCause: number | undefined, - issueId: string -): number { - // If only one cause and none specified, use it - if (causes.length === 1 && selectedCause === undefined) { - return 0; - } - - // If multiple causes and none specified, error with list - if (causes.length > 1 && selectedCause === undefined) { - const lines = [ - "Multiple root causes found. Please specify one with --cause :", - "", - ]; - for (let i = 0; i < causes.length; i++) { - const cause = causes[i]; - if (cause) { - lines.push(` ${i}: ${cause.description.slice(0, 60)}...`); - } - } - lines.push(""); - lines.push(`Example: sentry issue plan ${issueId} --cause 0`); - throw new ValidationError(lines.join("\n")); - } - - const causeId = selectedCause ?? 0; - - // Validate the cause ID is in range - if (causeId < 0 || causeId >= causes.length) { - throw new ValidationError( - `Invalid cause ID: ${causeId}. Valid range is 0-${causes.length - 1}.` - ); - } - - return causeId; -} - /** Context about why no solution was produced */ type NoSolutionContext = { /** Seer's reason for not producing a solution (from the artifact) */ @@ -172,11 +105,10 @@ function formatPlanOutput(data: PlanData): string { * Returns undefined when there's nothing useful to report. */ function buildNoSolutionContext( - state: AutofixState, - selectedCause?: RootCause + state: AutofixState ): NoSolutionContext | undefined { const reason = extractNoSolutionReason(state); - const cause = selectedCause ?? extractRootCauses(state)[0]; + const cause = extractRootCauses(state)[0]; const files = cause ? extractExaminedFiles([cause]) : []; if (!(reason || cause?.description) && files.length === 0) { @@ -206,10 +138,7 @@ function buildNoSolutionContext( * API, root cause description, and files examined) so the user isn't left * with a bare "no solution found" message. */ -function buildPlanData( - state: AutofixState, - selectedCause?: RootCause -): PlanData { +function buildPlanData(state: AutofixState): PlanData { const solution = extractSolution(state); const data: PlanData = { run_id: state.run_id, @@ -218,7 +147,7 @@ function buildPlanData( }; if (!solution) { - data.no_solution_context = buildNoSolutionContext(state, selectedCause); + data.no_solution_context = buildNoSolutionContext(state); } return data; @@ -231,7 +160,6 @@ export const planCommand = buildCommand({ "Generate a solution plan for a Sentry issue using Seer AI.\n\n" + "This command automatically runs root cause analysis if needed, then " + "generates a solution plan with specific implementation steps to fix the issue.\n\n" + - "If multiple root causes are identified, use --cause to specify which one.\n" + "Use --force to regenerate a plan even if one already exists.\n\n" + "Issue formats:\n" + " @latest - Most recent unresolved issue\n" + @@ -246,10 +174,10 @@ export const planCommand = buildCommand({ " - GitHub integration configured for your organization\n" + " - Code mappings set up for your project\n\n" + "Examples:\n" + - " sentry issue plan @latest --cause 0\n" + - " sentry issue plan 123456789 --cause 0\n" + - " sentry issue plan sentry/EXTENSION-7 --cause 1\n" + - " sentry issue plan cli-G --cause 0\n" + + " sentry issue plan @latest\n" + + " sentry issue plan 123456789\n" + + " sentry issue plan sentry/EXTENSION-7\n" + + " sentry issue plan cli-G\n" + " sentry issue plan 123456789 --force", }, output: { @@ -258,12 +186,6 @@ export const planCommand = buildCommand({ parameters: { positional: issueIdPositional, flags: { - cause: { - kind: "parsed", - parse: numberParser, - brief: "Root cause ID to plan (required if multiple causes exist)", - optional: true, - }, force: { kind: "boolean", brief: "Force new plan even if one exists", @@ -277,11 +199,9 @@ export const planCommand = buildCommand({ applyFreshFlag(flags); const { cwd } = this; - // Declare org outside try block so it's accessible in catch for error messages let resolvedOrg: string | undefined; try { - // Resolve org and issue ID const { org, issueId: numericId } = await resolveOrgAndIssueId({ issueArg, cwd, @@ -296,41 +216,28 @@ export const planCommand = buildCommand({ json: flags.json, }); - // Validate we have root causes - const causes = validateRootCauses(state); - - // Validate cause selection (always returns a valid index into causes) - const causeIndex = validateCauseSelection(causes, flags.cause, issueArg); - const selectedCause = causes.at(causeIndex); - if (!selectedCause) { - throw new ValidationError( - `Invalid cause index: ${causeIndex}. Valid range is 0-${causes.length - 1}.` - ); - } - // Check if solution already exists (skip if --force) if (!flags.force) { const existingSolution = extractSolution(state); if (existingSolution) { - return yield new CommandOutput(buildPlanData(state, selectedCause)); + return yield new CommandOutput(buildPlanData(state)); } } - // No solution exists, trigger planning - if (!flags.json) { + // Trigger solution planning + const causes = extractRootCauses(state); + if (!flags.json && causes.length > 0) { const log = logger.withTag("issue.plan"); - log.info(`Creating plan for cause #${causeIndex}...`); - log.info(`"${selectedCause.description}"`); + const cause = causes[0]; + if (cause) { + log.info("Creating plan..."); + log.info(`"${cause.description}"`); + } } - await triggerSolutionPlanning( - org, - numericId, - state.run_id, - selectedCause.id - ); + await triggerSolutionPlanning(org, numericId, state.run_id); - // Poll until solution is ready (NEED_MORE_INFORMATION) or terminal + // Poll until solution is ready or terminal const finalState = await pollAutofixState({ orgSlug: org, issueId: numericId, @@ -343,7 +250,6 @@ export const planCommand = buildCommand({ ` Or retry: sentry issue plan ${issueArg}`, }); - // Handle errors if (finalState.status === "ERROR") { throw new Error( "Plan creation failed. Check the Sentry web UI for details." @@ -354,9 +260,8 @@ export const planCommand = buildCommand({ throw new Error("Plan creation was cancelled."); } - return yield new CommandOutput(buildPlanData(finalState, selectedCause)); + return yield new CommandOutput(buildPlanData(finalState)); } catch (error) { - // Handle API errors with friendly messages if (error instanceof ApiError) { throw handleSeerApiError(error.status, error.detail, resolvedOrg); } diff --git a/src/lib/api/seer.ts b/src/lib/api/seer.ts index ab986a6ff..769d4eeea 100644 --- a/src/lib/api/seer.ts +++ b/src/lib/api/seer.ts @@ -2,24 +2,44 @@ * Seer AI API functions * * Functions for Seer-powered root cause analysis, autofix state, - * and solution planning. + * and solution planning. Uses the agent-based (explorer) endpoint + * which returns blocks instead of steps. */ -import { retrieveSeerIssueFixState, startSeerIssueFix } from "@sentry/api"; - import type { AutofixResponse, AutofixState } from "../../types/seer.js"; import { resolveOrgRegion } from "../region.js"; -import { - apiRequestToRegion, - getOrgSdkConfig, - unwrapResult, -} from "./infrastructure.js"; +import { apiRequestToRegion } from "./infrastructure.js"; + +/** Query params to activate the agent-based autofix endpoint */ +const EXPLORER_MODE_PARAMS = { mode: "explorer" }; + +/** + * Normalize agent status values to the uppercase format used throughout the CLI. + * + * The agent endpoint returns lowercase statuses (`processing`, `completed`, + * `error`, `awaiting_user_input`) while the CLI expects uppercase + * (`PROCESSING`, `COMPLETED`, `ERROR`, `WAITING_FOR_USER_RESPONSE`). + */ +function normalizeAgentStatus(status: string): string { + switch (status) { + case "processing": + return "PROCESSING"; + case "completed": + return "COMPLETED"; + case "error": + return "ERROR"; + case "awaiting_user_input": + return "WAITING_FOR_USER_RESPONSE"; + default: + return status.toUpperCase(); + } +} /** * Trigger root cause analysis for an issue using Seer AI. - * Uses region-aware routing for multi-region support. + * Uses the agent-based endpoint with region-aware routing. * * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID @@ -30,26 +50,25 @@ export async function triggerRootCauseAnalysis( orgSlug: string, issueId: string ): Promise<{ run_id: number }> { - const config = await getOrgSdkConfig(orgSlug); - - const result = await startSeerIssueFix({ - ...config, - path: { - organization_id_or_slug: orgSlug, - issue_id: Number(issueId), - }, - body: { - stopping_point: "root_cause", - }, - }); + const regionUrl = await resolveOrgRegion(orgSlug); - const data = unwrapResult(result, "Failed to trigger root cause analysis"); - return data as unknown as { run_id: number }; + const { data } = await apiRequestToRegion<{ run_id: number }>( + regionUrl, + `/organizations/${orgSlug}/issues/${issueId}/autofix/`, + { + method: "POST", + params: EXPLORER_MODE_PARAMS, + body: { + step: "root_cause", + }, + } + ); + return data; } /** * Get the current autofix state for an issue. - * Uses region-aware routing for multi-region support. + * Uses the agent-based endpoint with region-aware routing. * * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID @@ -59,54 +78,53 @@ export async function getAutofixState( orgSlug: string, issueId: string ): Promise { - const config = await getOrgSdkConfig(orgSlug); + const regionUrl = await resolveOrgRegion(orgSlug); + + const { data } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/issues/${issueId}/autofix/`, + { + params: EXPLORER_MODE_PARAMS, + } + ); - const result = await retrieveSeerIssueFixState({ - ...config, - path: { - organization_id_or_slug: orgSlug, - issue_id: Number(issueId), - }, - }); + if (!data.autofix) { + return null; + } - const data = unwrapResult(result, "Failed to get autofix state"); - const autofixResponse = data as unknown as AutofixResponse; - return autofixResponse.autofix; + // Normalize agent status to uppercase format used by the CLI + data.autofix.status = normalizeAgentStatus(data.autofix.status); + return data.autofix; } /** * Trigger solution planning for an existing autofix run. * - * Sends a `select_root_cause` update with `stopping_point: "solution"` to the - * autofix update endpoint. This tells Seer to proceed from root cause analysis + * Posts to the agent-based autofix endpoint with `step: "solution"` and + * the existing `run_id`. The agent continues from root cause analysis * to generating a solution plan. * * @param orgSlug - The organization slug * @param issueId - The numeric Sentry issue ID * @param runId - The autofix run ID - * @param causeId - The root cause ID to plan a solution for * @returns The response from the API */ export async function triggerSolutionPlanning( orgSlug: string, issueId: string, - runId: number, - causeId: number + runId: number ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); const { data } = await apiRequestToRegion( regionUrl, - `/organizations/${orgSlug}/issues/${issueId}/autofix/update/`, + `/organizations/${orgSlug}/issues/${issueId}/autofix/`, { method: "POST", + params: EXPLORER_MODE_PARAMS, body: { + step: "solution", run_id: runId, - payload: { - type: "select_root_cause", - cause_id: causeId, - stopping_point: "solution", - }, }, } ); diff --git a/src/lib/formatters/seer.ts b/src/lib/formatters/seer.ts index 20c5c069b..bc5fee9a3 100644 --- a/src/lib/formatters/seer.ts +++ b/src/lib/formatters/seer.ts @@ -60,18 +60,45 @@ export function formatProgressLine(message: string, tick: number): string { return `${spinner} ${message}`; } +/** Agent block with optional message content */ +type BlockWithMessage = { message?: { content?: string | null } }; + +/** + * Extract the first line of the latest block's message content. + * Returns undefined if no block message is available. + */ +function getBlockProgressMessage( + blocks: BlockWithMessage[] +): string | undefined { + const lastBlock = blocks.at(-1); + if (!lastBlock?.message?.content) { + return; + } + return lastBlock.message.content.split("\n")[0] || undefined; +} + /** * Extract the latest progress message from autofix state. * - * Only returns progress from the most recent (last) step to avoid showing - * stale messages from earlier phases (e.g. RCA progress during solution polling). + * Handles both response formats: + * - Agent: message content from the last block's `message.content` + * - Legacy: progress messages from the last step's `progress[]` array * * @param state - Current autofix state * @returns Latest progress message or default */ export function getProgressMessage(state: AutofixState): string { + const stateWithBlocks = state as AutofixState & { + blocks?: BlockWithMessage[]; + }; + if (stateWithBlocks.blocks && stateWithBlocks.blocks.length > 0) { + const msg = getBlockProgressMessage(stateWithBlocks.blocks); + if (msg) { + return msg; + } + } + if (state.steps && state.steps.length > 0) { - // Only look at the last step — earlier steps belong to previous phases const currentStep = state.steps.at(-1); if (currentStep?.progress && currentStep.progress.length > 0) { const lastProgress = currentStep.progress.at(-1); @@ -81,7 +108,6 @@ export function getProgressMessage(state: AutofixState): string { } } - // Fallback based on status switch (state.status) { case "PROCESSING": return "Analyzing issue..."; diff --git a/src/types/seer.ts b/src/types/seer.ts index 28c0101e9..74fb3193b 100644 --- a/src/types/seer.ts +++ b/src/types/seer.ts @@ -242,11 +242,12 @@ export function isTerminalStatus(status: string): boolean { return TERMINAL_STATUSES.includes(status as AutofixStatus); } -/** Container that may hold root cause analysis data */ +/** Container that may hold root cause analysis data (legacy format) */ type WithCauses = { key: string; causes?: RootCause[] }; /** - * Search an array of containers (blocks or steps) for root causes. + * Search an array of containers (blocks or steps) for root causes + * in the legacy format where causes are stored directly on the container. */ function searchContainersForRootCauses( containers: WithCauses[] @@ -259,24 +260,73 @@ function searchContainersForRootCauses( return null; } +/** Agent root cause artifact data from the explorer endpoint */ +type AgentRootCauseData = { + one_line_description: string; + five_whys?: string[]; + reproduction_steps?: string[]; + relevant_repo?: string | null; +}; + +/** + * Search blocks for root cause artifacts in the agent format. + * + * The agent endpoint stores root causes as artifacts with `key: "root_cause"` + * and data `{ one_line_description, five_whys, reproduction_steps, relevant_repo }`. + * Maps to the existing {@link RootCause} shape for downstream compatibility. + */ +function searchBlocksForAgentRootCause( + blocks: WithArtifacts[] +): RootCause[] | null { + for (const block of blocks) { + if (!block.artifacts) { + continue; + } + for (const artifact of block.artifacts) { + if (artifact.key === "root_cause" && artifact.data) { + const agentData = artifact.data as AgentRootCauseData; + const cause: RootCause = { + id: 0, + description: agentData.one_line_description, + relevant_repos: agentData.relevant_repo + ? [agentData.relevant_repo] + : undefined, + }; + return [cause]; + } + } + } + return null; +} + /** * Extract root causes from autofix state. - * Searches through both blocks and steps for root cause analysis data. * - * @param state - The autofix state containing analysis steps + * Searches through blocks and steps for root cause data in multiple formats: + * 1. Legacy step/block format: containers with `key: "root_cause_analysis"` and `causes[]` + * 2. Agent artifact format: blocks with artifacts `key: "root_cause"` containing + * `{ one_line_description, five_whys, reproduction_steps, relevant_repo }` + * + * @param state - The autofix state containing analysis data * @returns Array of root causes, or empty array if none found */ export function extractRootCauses(state: AutofixState): RootCause[] { const stateWithExtras = state as AutofixState & { - blocks?: WithCauses[]; + blocks?: (WithCauses & WithArtifacts)[]; steps?: WithCauses[]; }; if (stateWithExtras.blocks) { + // Try legacy format first (containers with causes[]) const causes = searchContainersForRootCauses(stateWithExtras.blocks); if (causes) { return causes; } + // Try agent artifact format (artifacts with key: "root_cause") + const agentCauses = searchBlocksForAgentRootCause(stateWithExtras.blocks); + if (agentCauses) { + return agentCauses; + } } if (stateWithExtras.steps) { diff --git a/test/lib/api-client.seer.test.ts b/test/lib/api-client.seer.test.ts index b86b73772..bf4a6088f 100644 --- a/test/lib/api-client.seer.test.ts +++ b/test/lib/api-client.seer.test.ts @@ -33,7 +33,7 @@ afterEach(() => { }); describe("triggerRootCauseAnalysis", () => { - test("sends POST request to autofix endpoint", async () => { + test("sends POST request to autofix endpoint with explorer mode", async () => { let capturedRequest: Request | undefined; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -51,9 +51,10 @@ describe("triggerRootCauseAnalysis", () => { expect(capturedRequest?.url).toContain( "/organizations/test-org/issues/123456789/autofix/" ); + expect(capturedRequest?.url).toContain("mode=explorer"); }); - test("includes step in request body", async () => { + test("includes step and stopping_point in request body", async () => { let capturedBody: unknown; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -68,7 +69,9 @@ describe("triggerRootCauseAnalysis", () => { await triggerRootCauseAnalysis("test-org", "123456789"); - expect(capturedBody).toEqual({ stopping_point: "root_cause" }); + expect(capturedBody).toEqual({ + step: "root_cause", + }); }); test("throws ApiError on 402 response", async () => { @@ -97,7 +100,7 @@ describe("triggerRootCauseAnalysis", () => { }); describe("getAutofixState", () => { - test("sends GET request to autofix endpoint", async () => { + test("sends GET request to autofix endpoint with explorer mode", async () => { let capturedRequest: Request | undefined; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -107,8 +110,9 @@ describe("getAutofixState", () => { JSON.stringify({ autofix: { run_id: 12_345, - status: "PROCESSING", - steps: [], + status: "processing", + blocks: [], + updated_at: "2025-01-01T00:00:00Z", }, }), { @@ -126,6 +130,28 @@ describe("getAutofixState", () => { expect(capturedRequest?.url).toContain( "/organizations/test-org/issues/123456789/autofix/" ); + expect(capturedRequest?.url).toContain("mode=explorer"); + }); + + test("normalizes agent status values to uppercase", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 1, + status: "awaiting_user_input", + blocks: [], + updated_at: "2025-01-01T00:00:00Z", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + + const result = await getAutofixState("test-org", "123456789"); + expect(result?.status).toBe("WAITING_FOR_USER_RESPONSE"); }); test("returns null when autofix is null", async () => { @@ -139,23 +165,27 @@ describe("getAutofixState", () => { expect(result).toBeNull(); }); - test("returns completed state with steps", async () => { + test("returns completed state with blocks", async () => { globalThis.fetch = async () => new Response( JSON.stringify({ autofix: { run_id: 12_345, - status: "COMPLETED", - steps: [ + status: "completed", + updated_at: "2025-01-01T00:00:00Z", + blocks: [ { - id: "step-1", - key: "root_cause_analysis", - status: "COMPLETED", - title: "Root Cause Analysis", - causes: [ + id: "block-1", + message: { role: "assistant", content: "Found the root cause" }, + timestamp: "2025-01-01T00:00:00Z", + artifacts: [ { - id: 0, - description: "Test cause", + key: "root_cause", + data: { + one_line_description: "Test cause", + five_whys: ["Why 1"], + }, + reason: "", }, ], }, @@ -170,13 +200,11 @@ describe("getAutofixState", () => { const result = await getAutofixState("test-org", "123456789"); expect(result?.status).toBe("COMPLETED"); - expect(result?.steps).toHaveLength(1); - expect(result?.steps?.[0]?.causes).toHaveLength(1); }); }); describe("triggerSolutionPlanning", () => { - test("sends POST request to autofix update endpoint", async () => { + test("sends POST request to autofix endpoint with explorer mode", async () => { let capturedRequest: Request | undefined; let capturedBody: unknown; @@ -184,25 +212,22 @@ describe("triggerSolutionPlanning", () => { capturedRequest = new Request(input, init); capturedBody = await new Request(input, init).json(); - return new Response(JSON.stringify({}), { + return new Response(JSON.stringify({ run_id: 12_345 }), { status: 202, headers: { "Content-Type": "application/json" }, }); }; - await triggerSolutionPlanning("test-org", "123456789", 12_345, 99); + await triggerSolutionPlanning("test-org", "123456789", 12_345); expect(capturedRequest?.method).toBe("POST"); expect(capturedRequest?.url).toContain( - "/organizations/test-org/issues/123456789/autofix/update/" + "/organizations/test-org/issues/123456789/autofix/" ); + expect(capturedRequest?.url).toContain("mode=explorer"); expect(capturedBody).toEqual({ + step: "solution", run_id: 12_345, - payload: { - type: "select_root_cause", - cause_id: 99, - stopping_point: "solution", - }, }); }); }); diff --git a/test/types/seer.test.ts b/test/types/seer.test.ts index 1aee4c0d8..957170367 100644 --- a/test/types/seer.test.ts +++ b/test/types/seer.test.ts @@ -194,6 +194,66 @@ describe("extractRootCauses", () => { expect(causes).toHaveLength(1); expect(causes[0]?.description).toBe("From blocks"); }); + + test("extracts root cause from agent artifact format", () => { + const state = { + run_id: 100, + status: "COMPLETED", + blocks: [ + { + id: "block-1", + message: { role: "assistant", content: "Analyzing..." }, + timestamp: "2025-01-01T00:00:00Z", + artifacts: [ + { + key: "root_cause", + data: { + one_line_description: "Null pointer in request handler", + five_whys: ["Missing null check", "No input validation"], + reproduction_steps: ["Send request without auth"], + relevant_repo: "org/backend", + }, + reason: "", + }, + ], + }, + ], + } as unknown as AutofixState; + + const causes = extractRootCauses(state); + expect(causes).toHaveLength(1); + expect(causes[0]?.description).toBe("Null pointer in request handler"); + expect(causes[0]?.relevant_repos).toEqual(["org/backend"]); + }); + + test("extracts root cause from agent artifact without relevant_repo", () => { + const state = { + run_id: 101, + status: "COMPLETED", + blocks: [ + { + id: "block-1", + message: { role: "assistant", content: "Done" }, + timestamp: "2025-01-01T00:00:00Z", + artifacts: [ + { + key: "root_cause", + data: { + one_line_description: "Configuration error", + five_whys: ["Wrong default value"], + }, + reason: "", + }, + ], + }, + ], + } as unknown as AutofixState; + + const causes = extractRootCauses(state); + expect(causes).toHaveLength(1); + expect(causes[0]?.description).toBe("Configuration error"); + expect(causes[0]?.relevant_repos).toBeUndefined(); + }); }); describe("extractNoSolutionReason", () => { @@ -540,6 +600,47 @@ describe("extractSolution", () => { expect(result).not.toBeNull(); expect(result!.data.steps[0]?.description).toBe(""); }); + + test("extracts solution from agent artifact format in blocks", () => { + const state = { + run_id: 10, + status: "COMPLETED", + blocks: [ + { + id: "block-1", + message: { role: "assistant", content: "Here is the solution" }, + timestamp: "2025-01-01T00:00:00Z", + artifacts: [ + { + key: "solution", + data: { + one_line_summary: "Add null check before property access", + steps: [ + { + title: "Add guard clause", + description: "Check if user is defined before accessing id", + }, + { + title: "Add error response", + description: "Return 401 for unauthenticated requests", + }, + ], + }, + reason: "", + }, + ], + }, + ], + } as unknown as AutofixState; + + const result = extractSolution(state); + expect(result).not.toBeNull(); + expect(result!.data.one_line_summary).toBe( + "Add null check before property access" + ); + expect(result!.data.steps).toHaveLength(2); + expect(result!.data.steps[0]?.title).toBe("Add guard clause"); + }); }); describe("extractExaminedFiles", () => {