From ff36454705ff00e29bb5ddecc57511a230dc2f37 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 15 Jun 2026 12:14:01 +0000 Subject: [PATCH 1/6] fix(seer): guard normalizeAgentStatus against null/undefined status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Seer API returns an autofix state with a null or missing status field, normalizeAgentStatus() would call status.toUpperCase() on the default branch, throwing a TypeError. This can happen when an autofix run is freshly created and has not yet received a status update. Default to 'PROCESSING' when status is falsy, matching the initial state of a new autofix run. Co-authored-by: Miguel Betegón --- src/lib/api/seer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/api/seer.ts b/src/lib/api/seer.ts index d0437f4731..7de74fdd55 100644 --- a/src/lib/api/seer.ts +++ b/src/lib/api/seer.ts @@ -28,7 +28,10 @@ const EXPLORER_MODE_PARAMS = { mode: "explorer" }; * returns false and polling spins until timeout. `awaiting_user_input` maps to * `WAITING_FOR_USER_RESPONSE`. */ -function normalizeAgentStatus(status: string): string { +function normalizeAgentStatus(status: string | null | undefined): string { + if (!status) { + return "PROCESSING"; + } switch (status) { case "processing": return "PROCESSING"; From f9cb519c333609049809c49cf713b99f1ce2fa41 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 15 Jun 2026 12:16:27 +0000 Subject: [PATCH 2/6] fix(plan): block solution planning when root cause needs user input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ensureRootCauseAnalysis returns with WAITING_FOR_USER_RESPONSE status (meaning the user needs to select a root cause in the Sentry web UI), the plan command would proceed to call triggerSolutionPlanning anyway. This causes confusing failures or empty results. Now checks the state status after root cause analysis completes and throws a clear CliError directing the user to the Sentry UI. Also handles WAITING_FOR_USER_RESPONSE during solution polling to avoid the misleading 'could not identify a code fix' message. Relates to GitHub issue #958. Co-authored-by: Miguel Betegón --- src/commands/issue/plan.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 7ce8959f6b..203d200fc3 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -8,7 +8,7 @@ import type { SentryContext } from "../../context.js"; import { triggerSolutionPlanning } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { ApiError } from "../../lib/errors.js"; +import { ApiError, CliError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { formatSolution, @@ -216,6 +216,15 @@ export const planCommand = buildCommand({ json: flags.json, }); + // Root cause analysis requires user input in the Sentry UI before + // solution planning can proceed (e.g. selecting a root cause). + if (state.status === "WAITING_FOR_USER_RESPONSE") { + throw new CliError( + "Root cause analysis requires your input before a plan can be generated.\n" + + "Open the issue in Sentry to select a root cause, then re-run this command." + ); + } + // Check if solution already exists (skip if --force) if (!flags.force) { const existingSolution = extractSolution(state); @@ -260,6 +269,13 @@ export const planCommand = buildCommand({ throw new Error("Plan creation was cancelled."); } + if (finalState.status === "WAITING_FOR_USER_RESPONSE") { + throw new CliError( + "Plan creation requires your input in the Sentry UI.\n" + + "Open the issue in Sentry to provide the requested information, then re-run this command." + ); + } + return yield new CommandOutput(buildPlanData(finalState)); } catch (error) { if (error instanceof ApiError) { From 6bf6a32ddecbcb2a72e8324d61042a305ed395e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 15 Jun 2026 12:18:31 +0000 Subject: [PATCH 3/6] fix(monitor): guard against short schedule arrays in formatSchedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a monitor's interval schedule array has fewer than 2 elements (e.g. an empty array or single-element array from a misconfigured monitor), the formatter would produce 'every undefined undefined' in the SCHEDULE column. Added a length check before accessing schedule[0] and schedule[1], falling back to stringifying the first element for short arrays. Co-authored-by: Miguel Betegón --- src/commands/monitor/list.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/commands/monitor/list.ts b/src/commands/monitor/list.ts index bde6b5dda7..07ee4a2302 100644 --- a/src/commands/monitor/list.ts +++ b/src/commands/monitor/list.ts @@ -41,6 +41,9 @@ function formatSchedule(monitor: MonitorWithOrg): string { return ""; } if (Array.isArray(config.schedule)) { + if (config.schedule.length < 2) { + return String(config.schedule[0] ?? ""); + } return `every ${config.schedule[0]} ${config.schedule[1]}`; } return config.schedule; From 9be98b1a60b3a108b5dd58afdf5c615f9affe3f2 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Mon, 28 Sep 2026 15:11:56 +0000 Subject: [PATCH 4/6] fix(plan): prompt for root cause selection in the terminal When root cause analysis pauses on WAITING_FOR_USER_RESPONSE, interactive sessions now present the candidate root causes and let the user pick one in the terminal so planning can continue, instead of always erroring out. JSON output and non-TTY runs keep the clear Sentry-UI hint. Addresses BYK's review feedback on #1104. --- packages/cli/src/commands/issue/plan.ts | 47 ++++++- .../cli/test/commands/issue/plan.func.test.ts | 127 ++++++++++++++++++ 2 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 packages/cli/test/commands/issue/plan.func.test.ts diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index 35d92f3c6e..a049b18250 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -5,6 +5,7 @@ * Automatically runs root cause analysis if not already done. */ +import { isatty } from "node:tty"; import type { SentryContext } from "../../context.js"; import { triggerSolutionPlanning } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; @@ -154,6 +155,42 @@ function buildPlanData(state: AutofixState): PlanData { return data; } +/** + * Root cause analysis paused waiting for the user to pick a root cause. When the + * session is interactive (a TTY and not JSON output), present the candidate root + * causes and let the user choose in the terminal so planning can continue. + * Throws a {@link CliError} pointing at the Sentry UI when we cannot prompt + * (JSON mode, non-TTY, no candidates) or the user cancels the selection. + */ +async function ensureRootCauseSelected( + state: AutofixState, + json: boolean +): Promise { + const causes = json ? [] : extractRootCauses(state); + const canPrompt = isatty(0) && isatty(2) && causes.length > 0; + + if (canPrompt) { + const log = logger.withTag("issue.plan"); + const response = await log.prompt("Select a root cause to plan against:", { + type: "select", + options: causes.map((cause) => ({ + label: cause.description, + value: String(cause.id), + })), + }); + + // consola returns a non-string (Symbol(clack:cancel)) when cancelled. + if (typeof response === "string") { + return; + } + } + + throw new CliError( + "Root cause analysis requires your input before a plan can be generated.\n" + + "Open the issue in Sentry to select a root cause, then re-run this command." + ); +} + export const planCommand = buildCommand({ docs: { brief: "Generate a solution plan using Seer AI", @@ -217,13 +254,11 @@ export const planCommand = buildCommand({ json: flags.json, }); - // Root cause analysis requires user input in the Sentry UI before - // solution planning can proceed (e.g. selecting a root cause). + // Root cause analysis can pause waiting for the user to pick a root + // cause. When running interactively, let the user choose in the terminal + // and continue; otherwise point them at the Sentry UI. if (state.status === "WAITING_FOR_USER_RESPONSE") { - throw new CliError( - "Root cause analysis requires your input before a plan can be generated.\n" + - "Open the issue in Sentry to select a root cause, then re-run this command." - ); + await ensureRootCauseSelected(state, flags.json); } // Check if solution already exists (skip if --force) diff --git a/packages/cli/test/commands/issue/plan.func.test.ts b/packages/cli/test/commands/issue/plan.func.test.ts new file mode 100644 index 0000000000..1e5d0ef299 --- /dev/null +++ b/packages/cli/test/commands/issue/plan.func.test.ts @@ -0,0 +1,127 @@ +/** + * Tests for the `issue plan` command's handling of the + * WAITING_FOR_USER_RESPONSE root-cause state. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../../../src/commands/issue/utils.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/commands/issue/utils.js") + >(); + return Object.fromEntries( + Object.entries(actual).map(([key, value]) => [ + key, + typeof value === "function" ? vi.fn(value) : value, + ]) + ); +}); + +import { isatty } from "node:tty"; +import { planCommand } from "../../../src/commands/issue/plan.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as issueUtils from "../../../src/commands/issue/utils.js"; +import { CliError } from "../../../src/lib/errors.js"; +import { logger } from "../../../src/lib/logger.js"; +import type { AutofixState, RootCause } from "../../../src/types/seer.js"; + +vi.mock("node:tty", () => ({ isatty: vi.fn() })); + +vi.mock("../../../src/lib/api-client.js", () => ({ + triggerSolutionPlanning: vi.fn(async () => ({})), +})); + +const PLAN_FLAGS = { json: false, force: false, fresh: false } as const; + +function sampleCause(id: number, description: string): RootCause { + return { id, description }; +} + +function waitingState(causes: RootCause[]): AutofixState { + return { + status: "WAITING_FOR_USER_RESPONSE", + run_id: 42, + steps: [ + { + id: "root-cause", + key: "root_cause_analysis", + status: "WAITING_FOR_USER_RESPONSE", + title: "Root cause", + causes, + }, + ], + }; +} + +function createMockContext() { + return { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }; +} + +async function runPlan(context: ReturnType) { + const func = await planCommand.loader(); + await func.call(context, PLAN_FLAGS, "IOS-1"); +} + +describe("issue plan waiting-for-user-response", () => { + let resolveSpy: ReturnType; + let analyzeSpy: ReturnType; + let promptSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + resolveSpy = vi.spyOn(issueUtils, "resolveOrgAndIssueId"); + analyzeSpy = vi.spyOn(issueUtils, "ensureRootCauseAnalysis"); + promptSpy = vi.fn(); + // The command prompts through a tagged logger, so intercept withTag. + vi.spyOn(logger, "withTag").mockReturnValue({ + info: vi.fn(), + prompt: promptSpy, + } as unknown as ReturnType); + resolveSpy.mockResolvedValue({ org: "test-org", issueId: "1" }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("throws a UI hint when not interactive", async () => { + vi.mocked(isatty).mockReturnValue(false); + analyzeSpy.mockResolvedValue(waitingState([sampleCause(0, "A cause")])); + + await expect(runPlan(createMockContext())).rejects.toBeInstanceOf(CliError); + expect(promptSpy).not.toHaveBeenCalled(); + }); + + test("prompts for a root cause when interactive", async () => { + vi.mocked(isatty).mockReturnValue(true); + analyzeSpy.mockResolvedValue( + waitingState([sampleCause(0, "First"), sampleCause(1, "Second")]) + ); + promptSpy.mockResolvedValue("1"); + const solutionSpy = vi + .spyOn(issueUtils, "pollAutofixState") + .mockResolvedValue({ status: "COMPLETED", run_id: 42, steps: [] }); + + await runPlan(createMockContext()); + + expect(promptSpy).toHaveBeenCalledWith( + "Select a root cause to plan against:", + expect.objectContaining({ type: "select" }) + ); + expect(solutionSpy).toHaveBeenCalled(); + }); + + test("throws when the user cancels the selection", async () => { + vi.mocked(isatty).mockReturnValue(true); + analyzeSpy.mockResolvedValue(waitingState([sampleCause(0, "A cause")])); + // consola returns a cancel symbol (non-string) when cancelled. + promptSpy.mockResolvedValue(Symbol("clack:cancel")); + + await expect(runPlan(createMockContext())).rejects.toBeInstanceOf(CliError); + }); +}); From 4a9c354f883b89c11db2237f59577ea741620c88 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Mon, 28 Sep 2026 17:02:33 +0000 Subject: [PATCH 5/6] fix(plan): act on the root-cause confirmation instead of discarding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive prompt captured the user's selection but never submitted it, then advanced a run still in WAITING_FOR_USER_RESPONSE — flagged by Seer and Cursor review. Sentry has no CLI-facing endpoint to submit a specific cause_id (root-cause selection among candidates happens in the web UI), so make this a confirm-to-continue: show the identified root cause, ask whether to plan, and only then advance via triggerSolutionPlanning (the same step the Sentry UI uses). Declining or a non-TTY/JSON run keeps the clear Sentry-UI error. Add coverage: interactive confirm/decline/cancel, post-planning terminal states, existing-solution short-circuit, normalizeAgentStatus null guard, and formatSchedule edge cases. --- packages/cli/src/commands/issue/plan.ts | 46 ++++--- packages/cli/src/commands/monitor/list.ts | 2 +- packages/cli/src/lib/api/seer.ts | 2 +- .../cli/test/commands/issue/plan.func.test.ts | 117 +++++++++++++++++- .../cli/test/commands/monitor/list.test.ts | 44 +++++++ packages/cli/test/lib/api-client.seer.test.ts | 27 ++++ 6 files changed, 210 insertions(+), 28 deletions(-) create mode 100644 packages/cli/test/commands/monitor/list.test.ts diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index a049b18250..4a6db7edea 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -156,31 +156,37 @@ function buildPlanData(state: AutofixState): PlanData { } /** - * Root cause analysis paused waiting for the user to pick a root cause. When the - * session is interactive (a TTY and not JSON output), present the candidate root - * causes and let the user choose in the terminal so planning can continue. - * Throws a {@link CliError} pointing at the Sentry UI when we cannot prompt - * (JSON mode, non-TTY, no candidates) or the user cancels the selection. + * Root cause analysis paused waiting for the user to confirm the identified root + * cause before solution planning. When the session is interactive (a TTY and not + * JSON output), show the root cause and ask whether to continue; the caller then + * advances the run via {@link triggerSolutionPlanning}, which is how the Sentry + * UI proceeds from this state. + * + * The Sentry API has no endpoint to submit a specific `cause_id` from the CLI + * (selecting among candidates happens through the web UI's interactive run + * state), so this is a confirm-to-continue rather than a picker. Throws a + * {@link CliError} pointing at the Sentry UI when we cannot prompt (JSON mode, + * non-TTY, no root cause) or the user declines. */ -async function ensureRootCauseSelected( +async function confirmRootCauseAndContinue( state: AutofixState, json: boolean ): Promise { const causes = json ? [] : extractRootCauses(state); - const canPrompt = isatty(0) && isatty(2) && causes.length > 0; + const primaryCause = causes[0]; - if (canPrompt) { + if (isatty(0) && isatty(2) && primaryCause) { const log = logger.withTag("issue.plan"); - const response = await log.prompt("Select a root cause to plan against:", { - type: "select", - options: causes.map((cause) => ({ - label: cause.description, - value: String(cause.id), - })), + log.info("Root cause identified:"); + log.info(`"${primaryCause.description}"`); + + const proceed = await log.prompt("Generate a solution plan for it?", { + type: "confirm", + initial: true, }); - // consola returns a non-string (Symbol(clack:cancel)) when cancelled. - if (typeof response === "string") { + // consola returns a non-boolean (Symbol(clack:cancel)) when cancelled. + if (proceed === true) { return; } } @@ -254,11 +260,11 @@ export const planCommand = buildCommand({ json: flags.json, }); - // Root cause analysis can pause waiting for the user to pick a root - // cause. When running interactively, let the user choose in the terminal - // and continue; otherwise point them at the Sentry UI. + // Root cause analysis can pause waiting for the user to confirm the + // root cause before planning. When running interactively, confirm in the + // terminal and continue; otherwise point them at the Sentry UI. if (state.status === "WAITING_FOR_USER_RESPONSE") { - await ensureRootCauseSelected(state, flags.json); + await confirmRootCauseAndContinue(state, flags.json); } // Check if solution already exists (skip if --force) diff --git a/packages/cli/src/commands/monitor/list.ts b/packages/cli/src/commands/monitor/list.ts index dde303b026..36648a9922 100644 --- a/packages/cli/src/commands/monitor/list.ts +++ b/packages/cli/src/commands/monitor/list.ts @@ -35,7 +35,7 @@ type MonitorWithOrg = SentryMonitor & { orgSlug?: string }; * Interval schedules show `"every "` (e.g. `"every 1 hour"`). * Returns an empty string when no schedule is configured. */ -function formatSchedule(monitor: MonitorWithOrg): string { +export function formatSchedule(monitor: MonitorWithOrg): string { const config = monitor.config; if (!config?.schedule) { return ""; diff --git a/packages/cli/src/lib/api/seer.ts b/packages/cli/src/lib/api/seer.ts index 17eb1acf04..6e37419c5e 100644 --- a/packages/cli/src/lib/api/seer.ts +++ b/packages/cli/src/lib/api/seer.ts @@ -32,7 +32,7 @@ const EXPLORER_MODE_PARAMS = { mode: "explorer" }; * returns false and polling spins until timeout. `awaiting_user_input` maps to * `WAITING_FOR_USER_RESPONSE`. */ -function normalizeAgentStatus(status: string | null | undefined): string { +export function normalizeAgentStatus(status: string | null | undefined): string { if (!status) { return "PROCESSING"; } diff --git a/packages/cli/test/commands/issue/plan.func.test.ts b/packages/cli/test/commands/issue/plan.func.test.ts index 1e5d0ef299..1c93a5ae50 100644 --- a/packages/cli/test/commands/issue/plan.func.test.ts +++ b/packages/cli/test/commands/issue/plan.func.test.ts @@ -22,6 +22,7 @@ import { isatty } from "node:tty"; import { planCommand } from "../../../src/commands/issue/plan.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as issueUtils from "../../../src/commands/issue/utils.js"; +import { triggerSolutionPlanning } from "../../../src/lib/api-client.js"; import { CliError } from "../../../src/lib/errors.js"; import { logger } from "../../../src/lib/logger.js"; import type { AutofixState, RootCause } from "../../../src/types/seer.js"; @@ -97,12 +98,12 @@ describe("issue plan waiting-for-user-response", () => { expect(promptSpy).not.toHaveBeenCalled(); }); - test("prompts for a root cause when interactive", async () => { + test("confirms the root cause and continues to planning when interactive", async () => { vi.mocked(isatty).mockReturnValue(true); analyzeSpy.mockResolvedValue( waitingState([sampleCause(0, "First"), sampleCause(1, "Second")]) ); - promptSpy.mockResolvedValue("1"); + promptSpy.mockResolvedValue(true); const solutionSpy = vi .spyOn(issueUtils, "pollAutofixState") .mockResolvedValue({ status: "COMPLETED", run_id: 42, steps: [] }); @@ -110,18 +111,122 @@ describe("issue plan waiting-for-user-response", () => { await runPlan(createMockContext()); expect(promptSpy).toHaveBeenCalledWith( - "Select a root cause to plan against:", - expect.objectContaining({ type: "select" }) + "Generate a solution plan for it?", + expect.objectContaining({ type: "confirm" }) ); + expect(triggerSolutionPlanning).toHaveBeenCalledWith("test-org", "1", 42); expect(solutionSpy).toHaveBeenCalled(); }); - test("throws when the user cancels the selection", async () => { + test("throws when the user declines to continue", async () => { vi.mocked(isatty).mockReturnValue(true); analyzeSpy.mockResolvedValue(waitingState([sampleCause(0, "A cause")])); - // consola returns a cancel symbol (non-string) when cancelled. + promptSpy.mockResolvedValue(false); + + await expect(runPlan(createMockContext())).rejects.toBeInstanceOf(CliError); + expect(triggerSolutionPlanning).not.toHaveBeenCalled(); + }); + + test("throws when the user cancels the prompt", async () => { + vi.mocked(isatty).mockReturnValue(true); + analyzeSpy.mockResolvedValue(waitingState([sampleCause(0, "A cause")])); + // consola returns a cancel symbol (non-boolean) when cancelled. promptSpy.mockResolvedValue(Symbol("clack:cancel")); await expect(runPlan(createMockContext())).rejects.toBeInstanceOf(CliError); + expect(triggerSolutionPlanning).not.toHaveBeenCalled(); + }); +}); + +function completedState(): AutofixState { + return { status: "COMPLETED", run_id: 42, steps: [] }; +} + +describe("issue plan after solution planning", () => { + let resolveSpy: ReturnType; + let analyzeSpy: ReturnType; + let pollSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isatty).mockReturnValue(false); + resolveSpy = vi.spyOn(issueUtils, "resolveOrgAndIssueId"); + analyzeSpy = vi.spyOn(issueUtils, "ensureRootCauseAnalysis"); + pollSpy = vi.spyOn(issueUtils, "pollAutofixState"); + resolveSpy.mockResolvedValue({ org: "test-org", issueId: "1" }); + analyzeSpy.mockResolvedValue(completedState()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("returns an existing solution without triggering planning", async () => { + analyzeSpy.mockResolvedValue({ + status: "COMPLETED", + run_id: 42, + steps: [ + { + id: "solution", + key: "solution", + status: "COMPLETED", + title: "Solution", + solution: [{ title: "Fix it", code_snippet_and_analysis: "do x" }], + }, + ], + } as AutofixState); + + await runPlan(createMockContext()); + + expect(triggerSolutionPlanning).not.toHaveBeenCalled(); + expect(pollSpy).not.toHaveBeenCalled(); + }); + + test("throws when planning ends in ERROR", async () => { + pollSpy.mockResolvedValue({ status: "ERROR", run_id: 42, steps: [] }); + + await expect(runPlan(createMockContext())).rejects.toThrow( + /Plan creation failed/ + ); + }); + + test("throws when planning ends in CANCELLED", async () => { + pollSpy.mockResolvedValue({ status: "CANCELLED", run_id: 42, steps: [] }); + + await expect(runPlan(createMockContext())).rejects.toThrow( + /Plan creation was cancelled/ + ); + }); + + test("throws a UI hint when planning still needs user input", async () => { + pollSpy.mockResolvedValue({ + status: "WAITING_FOR_USER_RESPONSE", + run_id: 42, + steps: [], + }); + + await expect(runPlan(createMockContext())).rejects.toBeInstanceOf(CliError); + }); + + test("emits the plan when planning completes", async () => { + pollSpy.mockResolvedValue({ + status: "COMPLETED", + run_id: 42, + steps: [ + { + id: "solution", + key: "solution", + status: "COMPLETED", + title: "Solution", + solution: [{ title: "Fix it", code_snippet_and_analysis: "do x" }], + }, + ], + } as AutofixState); + + const context = createMockContext(); + await runPlan(context); + + expect(triggerSolutionPlanning).toHaveBeenCalledWith("test-org", "1", 42); + expect(pollSpy).toHaveBeenCalled(); }); }); diff --git a/packages/cli/test/commands/monitor/list.test.ts b/packages/cli/test/commands/monitor/list.test.ts new file mode 100644 index 0000000000..c487e65d65 --- /dev/null +++ b/packages/cli/test/commands/monitor/list.test.ts @@ -0,0 +1,44 @@ +/** + * Tests for monitor list schedule formatting. + */ + +import { describe, expect, test } from "vitest"; +import { formatSchedule } from "../../../src/commands/monitor/list.js"; +import type { SentryMonitor } from "../../../src/types/index.js"; + +function monitor(config: SentryMonitor["config"]): SentryMonitor { + return { + id: "1", + slug: "m", + name: "Monitor", + status: "active", + config, + }; +} + +describe("formatSchedule", () => { + test("returns an empty string when no schedule is configured", () => { + expect(formatSchedule(monitor(undefined))).toBe(""); + expect(formatSchedule(monitor({}))).toBe(""); + }); + + test("renders a crontab schedule verbatim", () => { + expect(formatSchedule(monitor({ schedule: "0 * * * *" }))).toBe( + "0 * * * *" + ); + }); + + test("renders a well-formed interval schedule", () => { + expect(formatSchedule(monitor({ schedule: [1, "hour"] }))).toBe( + "every 1 hour" + ); + }); + + test("falls back to the first element for a single-element interval", () => { + expect(formatSchedule(monitor({ schedule: [5] }))).toBe("5"); + }); + + test("returns an empty string for an empty interval array", () => { + expect(formatSchedule(monitor({ schedule: [] }))).toBe(""); + }); +}); diff --git a/packages/cli/test/lib/api-client.seer.test.ts b/packages/cli/test/lib/api-client.seer.test.ts index d76a69b589..abf9a263c7 100644 --- a/packages/cli/test/lib/api-client.seer.test.ts +++ b/packages/cli/test/lib/api-client.seer.test.ts @@ -5,6 +5,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { normalizeAgentStatus } from "../../src/lib/api/seer.js"; import { getAutofixState, triggerRootCauseAnalysis, @@ -360,3 +361,29 @@ describe("triggerSolutionPlanning", () => { }); }); }); + +describe("normalizeAgentStatus", () => { + test.each([ + ["processing", "PROCESSING"], + ["completed", "COMPLETED"], + ["error", "ERROR"], + ["canceled", "CANCELLED"], + ["cancelled", "CANCELLED"], + ["awaiting_user_input", "WAITING_FOR_USER_RESPONSE"], + ["need_more_information", "NEED_MORE_INFORMATION"], + ])("maps %s to %s", (input, expected) => { + expect(normalizeAgentStatus(input)).toBe(expected); + }); + + test.each([ + [null], + [undefined], + [""], + ])("defaults %s status to PROCESSING", (input) => { + expect(normalizeAgentStatus(input)).toBe("PROCESSING"); + }); + + test("uppercases unknown statuses", () => { + expect(normalizeAgentStatus("some_new_status")).toBe("SOME_NEW_STATUS"); + }); +}); From cdeb04c78b4da05291c095750092b8843c3d9bb1 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Mon, 28 Sep 2026 17:33:04 +0000 Subject: [PATCH 6/6] style(seer): wrap normalizeAgentStatus signature to satisfy biome CI's biome formatter requires the multi-line signature after the export. --- packages/cli/src/lib/api/seer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/api/seer.ts b/packages/cli/src/lib/api/seer.ts index 6e37419c5e..8779d86733 100644 --- a/packages/cli/src/lib/api/seer.ts +++ b/packages/cli/src/lib/api/seer.ts @@ -32,7 +32,9 @@ const EXPLORER_MODE_PARAMS = { mode: "explorer" }; * returns false and polling spins until timeout. `awaiting_user_input` maps to * `WAITING_FOR_USER_RESPONSE`. */ -export function normalizeAgentStatus(status: string | null | undefined): string { +export function normalizeAgentStatus( + status: string | null | undefined +): string { if (!status) { return "PROCESSING"; }