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
14 changes: 7 additions & 7 deletions src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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");
}
Expand Down
13 changes: 11 additions & 2 deletions src/lib/api/seer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
}
Expand Down
68 changes: 55 additions & 13 deletions src/types/seer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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;
}
}

Expand Down
4 changes: 2 additions & 2 deletions test/commands/issue/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1134,7 +1134,7 @@ describe("issue list: collapse parameter optimization", () => {
const options = callArgs?.[2] as Record<string, unknown> | undefined;
const collapse = options?.collapse as string[];
expect(collapse).toContain("filtered");
expect(collapse).toContain("lifetime");
expect(collapse).not.toContain("lifetime");
expect(collapse).toContain("unhandled");
});

Expand Down
42 changes: 42 additions & 0 deletions test/lib/api-client.seer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }), {
Expand Down
19 changes: 15 additions & 4 deletions test/lib/issue-collapse.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 }
);
Expand Down
57 changes: 57 additions & 0 deletions test/types/seer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading