diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index 66155a917..d38d76df2 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -59,13 +59,13 @@ export type IssueCollapseField = NonNullable< /** * Build the `collapse` parameter for issue list API calls. * - * Always collapses fields the CLI never consumes in issue list: - * `filtered`, `lifetime`, `unhandled`. Conditionally collapses `stats` - * when sparklines won't be rendered (narrow terminal, non-TTY, or JSON). + * Collapses `filtered` and `unhandled` which the CLI never uses in list views. + * Does NOT collapse `lifetime` — that suppresses `count`, `userCount`, and + * `firstSeen` on the list endpoint, which the CLI needs for table columns + * (EVENTS, USERS, AGE) and JSON output. * - * Matches the Sentry web UI's optimization: the initial page load sends - * `collapse=stats,unhandled` to skip expensive Snuba queries, fetching - * stats in a follow-up request only when needed. + * Conditionally collapses `stats` when sparklines won't be rendered + * (narrow terminal, non-TTY, or JSON mode). * * @param options - Context for determining what to collapse * @param options.shouldCollapseStats - Whether stats data can be skipped @@ -75,7 +75,7 @@ export type IssueCollapseField = NonNullable< export function buildIssueListCollapse(options: { shouldCollapseStats: boolean; }): IssueCollapseField[] { - const collapse: IssueCollapseField[] = ["filtered", "lifetime", "unhandled"]; + const collapse: IssueCollapseField[] = ["filtered", "unhandled"]; if (options.shouldCollapseStats) { collapse.push("stats"); } diff --git a/src/lib/api/seer.ts b/src/lib/api/seer.ts index c96127410..9fe113b78 100644 --- a/src/lib/api/seer.ts +++ b/src/lib/api/seer.ts @@ -19,8 +19,12 @@ 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`). + * `error`, `awaiting_user_input`, `canceled`) while the CLI expects uppercase + * (`PROCESSING`, `COMPLETED`, `ERROR`, `WAITING_FOR_USER_RESPONSE`, `CANCELLED`). + * + * Explicit mappings are needed for statuses whose CLI name differs from a naive + * toUpperCase() — e.g. `canceled` (US) → `CANCELLED` (British, used in + * TERMINAL_STATUSES), and `awaiting_user_input` → `WAITING_FOR_USER_RESPONSE`. */ function normalizeAgentStatus(status: string): string { switch (status) { @@ -30,8 +34,13 @@ function normalizeAgentStatus(status: string): string { return "COMPLETED"; case "error": return "ERROR"; + case "canceled": + case "cancelled": + return "CANCELLED"; case "awaiting_user_input": return "WAITING_FOR_USER_RESPONSE"; + case "need_more_information": + return "NEED_MORE_INFORMATION"; default: return status.toUpperCase(); } diff --git a/src/types/seer.ts b/src/types/seer.ts index 74fb3193b..b06910483 100644 --- a/src/types/seer.ts +++ b/src/types/seer.ts @@ -380,7 +380,7 @@ function findNoSolutionReason(artifacts: ArtifactEntry[]): string | undefined { } /** - * Search containers (blocks or steps) for a no-solution reason. + * Search containers (blocks or steps) for a no-solution reason in artifacts. */ function searchContainersForNoSolutionReason( containers: WithArtifacts[] @@ -396,6 +396,28 @@ function searchContainersForNoSolutionReason( return; } +/** + * Search containers for step-level no-solution reason. + * + * The Seer API returns solution data directly on steps with `key === "solution"`. + * When no solution is produced, the step has an empty/missing `solution` array + * but its `description` field carries the reason why no fix was found. + */ +function searchContainersForStepLevelNoSolutionReason( + containers: StepWithSolution[] +): string | undefined { + for (const container of containers) { + if ( + container.key === "solution" && + (!container.solution || container.solution.length === 0) && + container.description + ) { + return container.description; + } + } + return; +} + /** * Search an array of containers (blocks or steps) for a solution artifact. */ @@ -513,32 +535,52 @@ export function extractSolution(state: AutofixState): SolutionArtifact | null { /** * Extract the reason why no solution was produced. * - * When Seer completes analysis but cannot produce a code fix, the API - * returns a solution artifact with `data: null` and a `reason` string. - * This function searches blocks and steps for that reason. + * Searches blocks and steps for a no-solution reason in two places: + * 1. Step-level: `step.key === "solution"` with empty `solution[]` and a + * `description` explaining why (current API format). + * 2. Artifact-level: `artifact.key === "solution"` with a `reason` field + * (legacy format, kept as fallback). * - * @param state - Autofix state (may contain blocks or steps with artifacts) + * @param state - Autofix state (may contain blocks or steps with solution data) * @returns Reason string if found, undefined otherwise */ export function extractNoSolutionReason( state: AutofixState ): string | undefined { const stateWithExtras = state as AutofixState & { - blocks?: WithArtifacts[]; - steps?: WithArtifacts[]; + blocks?: (WithArtifacts & StepWithSolution)[]; + steps?: (WithArtifacts & StepWithSolution)[]; }; + // Search blocks first (explorer mode / newer API) if (stateWithExtras.blocks) { - const reason = searchContainersForNoSolutionReason(stateWithExtras.blocks); - if (reason) { - return reason; + const stepLevel = searchContainersForStepLevelNoSolutionReason( + stateWithExtras.blocks + ); + if (stepLevel) { + return stepLevel; + } + const artifactLevel = searchContainersForNoSolutionReason( + stateWithExtras.blocks + ); + if (artifactLevel) { + return artifactLevel; } } + // Search steps (regular autofix API) if (stateWithExtras.steps) { - const reason = searchContainersForNoSolutionReason(stateWithExtras.steps); - if (reason) { - return reason; + const stepLevel = searchContainersForStepLevelNoSolutionReason( + stateWithExtras.steps + ); + if (stepLevel) { + return stepLevel; + } + const artifactLevel = searchContainersForNoSolutionReason( + stateWithExtras.steps + ); + if (artifactLevel) { + return artifactLevel; } } diff --git a/test/commands/issue/list.test.ts b/test/commands/issue/list.test.ts index bc26aee05..e6a4155e5 100644 --- a/test/commands/issue/list.test.ts +++ b/test/commands/issue/list.test.ts @@ -1110,7 +1110,7 @@ describe("issue list: collapse parameter optimization", () => { advancePaginationStateSpy.mockRestore(); }); - test("always collapses filtered, lifetime, unhandled in org-all mode", async () => { + test("always collapses filtered and unhandled (not lifetime) in org-all mode", async () => { listIssuesPaginatedSpy.mockResolvedValue({ data: [sampleIssue], nextCursor: undefined, @@ -1134,7 +1134,7 @@ describe("issue list: collapse parameter optimization", () => { const options = callArgs?.[2] as Record | undefined; const collapse = options?.collapse as string[]; expect(collapse).toContain("filtered"); - expect(collapse).toContain("lifetime"); + expect(collapse).not.toContain("lifetime"); expect(collapse).toContain("unhandled"); }); diff --git a/test/lib/api-client.seer.test.ts b/test/lib/api-client.seer.test.ts index 2ffc1bf4a..dd79b6bab 100644 --- a/test/lib/api-client.seer.test.ts +++ b/test/lib/api-client.seer.test.ts @@ -155,6 +155,48 @@ describe("getAutofixState", () => { expect(result?.status).toBe("WAITING_FOR_USER_RESPONSE"); }); + test("normalizes US spelling 'canceled' to CANCELLED for terminal status match", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 1, + status: "canceled", + 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("CANCELLED"); + }); + + test("normalizes need_more_information to NEED_MORE_INFORMATION", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + autofix: { + run_id: 1, + status: "need_more_information", + 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("NEED_MORE_INFORMATION"); + }); + test("returns null when autofix is null", async () => { globalThis.fetch = async () => new Response(JSON.stringify({ autofix: null }), { diff --git a/test/lib/issue-collapse.property.test.ts b/test/lib/issue-collapse.property.test.ts index 4938d29ae..73bc8eedd 100644 --- a/test/lib/issue-collapse.property.test.ts +++ b/test/lib/issue-collapse.property.test.ts @@ -15,20 +15,31 @@ import { import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; describe("property: buildIssueListCollapse", () => { - test("always collapses filtered, lifetime, unhandled regardless of stats flag", () => { + test("always collapses filtered and unhandled regardless of stats flag", () => { fcAssert( property(boolean(), (collapseStats) => { const result = buildIssueListCollapse({ shouldCollapseStats: collapseStats, }); expect(result).toContain("filtered"); - expect(result).toContain("lifetime"); expect(result).toContain("unhandled"); }), { numRuns: DEFAULT_NUM_RUNS } ); }); + test("never collapses lifetime (CLI needs count, userCount, firstSeen)", () => { + fcAssert( + property(boolean(), (collapseStats) => { + const result = buildIssueListCollapse({ + shouldCollapseStats: collapseStats, + }); + expect(result).not.toContain("lifetime"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + test("stats presence is exactly controlled by shouldCollapseStats", () => { fcAssert( property(boolean(), (collapseStats) => { @@ -65,13 +76,13 @@ describe("property: buildIssueListCollapse", () => { ); }); - test("length is 3 without stats, 4 with stats", () => { + test("length is 2 without stats, 3 with stats", () => { fcAssert( property(boolean(), (collapseStats) => { const result = buildIssueListCollapse({ shouldCollapseStats: collapseStats, }); - expect(result.length).toBe(collapseStats ? 4 : 3); + expect(result.length).toBe(collapseStats ? 3 : 2); }), { numRuns: DEFAULT_NUM_RUNS } ); diff --git a/test/types/seer.test.ts b/test/types/seer.test.ts index 957170367..84e46acef 100644 --- a/test/types/seer.test.ts +++ b/test/types/seer.test.ts @@ -345,6 +345,63 @@ describe("extractNoSolutionReason", () => { const state: AutofixState = { run_id: 1, status: "COMPLETED" }; expect(extractNoSolutionReason(state)).toBeUndefined(); }); + + test("extracts reason from step-level description when solution is empty", () => { + const state = { + run_id: 1, + status: "NEED_MORE_INFORMATION", + blocks: [ + { + key: "solution", + description: + "Cannot produce a fix: the issue is in a third-party library", + solution: [], + artifacts: [], + }, + ], + } as unknown as AutofixState; + + expect(extractNoSolutionReason(state)).toBe( + "Cannot produce a fix: the issue is in a third-party library" + ); + }); + + test("extracts reason from step-level when solution field is missing", () => { + const state = { + run_id: 1, + status: "NEED_MORE_INFORMATION", + steps: [ + { + key: "solution", + description: "Infrastructure-level issue, no code change applicable", + artifacts: [], + }, + ], + } as unknown as AutofixState; + + expect(extractNoSolutionReason(state)).toBe( + "Infrastructure-level issue, no code change applicable" + ); + }); + + test("prefers step-level reason over artifact-level reason", () => { + const state = { + run_id: 1, + status: "COMPLETED", + blocks: [ + { + key: "solution", + description: "Step-level reason", + solution: [], + artifacts: [ + { key: "solution", data: null, reason: "Artifact-level reason" }, + ], + }, + ], + } as unknown as AutofixState; + + expect(extractNoSolutionReason(state)).toBe("Step-level reason"); + }); }); describe("extractSolution", () => {