Skip to content

Add support for seer interactive mode #425

@BYK

Description

@BYK

Plan: Add Seer Interactive Mode (sentry issue fix)

Summary

Add a new sentry issue fix <issue> command that runs the full Seer autofix pipeline interactively. Unlike explain (root cause only) and plan (solution only), fix runs the entire pipeline from root cause → solution → code changes, with an interactive loop that handles Seer's questions when it needs user input. The key differentiator from the web UI is local patch application — patches can be applied directly to the working tree.

Approach: New fix Command Using Explorer Mode API

The explorer mode API (?mode=explorer) provides the interactive conversation model with blocks, pending user input, and chat continuation. The legacy mode doesn't support the interactive chat flow well.

Key design decisions:

  • JSON mode (Phase 1): Non-interactive — polls to completion, emits final result. NDJSON streaming for agent interactivity is a future enhancement.
  • Default output: Stop at code_changes — show the diff, then prompt the user to either (a) apply patches locally or (b) create a PR. In --json mode, return the patches/diffs in the response for agents to consume.
  • Local patch application: Since the CLI runs locally, the primary value-add over the web UI is applying Seer's patches directly to the working tree. This is the differentiator.

API endpoints needed:

  • POST /organizations/{org}/issues/{id}/autofix/?mode=explorer — trigger with stopping_point: "code_changes"
  • GET /organizations/{org}/issues/{id}/autofix/?mode=explorer — poll state (returns blocks, pending_user_input, repo_pr_states)
  • POST /organizations/{org}/seer/explorer-chat/{run_id}/ — send user messages when Seer asks questions
  • POST /organizations/{org}/seer/explorer-update/{run_id}/ — send updates (create_pr)

Files to Create/Modify

1. src/types/seer.ts — Add Explorer Types

Add new types for the explorer mode response:

// Explorer mode block types
export const MessageSchema = z.object({
  role: z.enum(["user", "assistant", "tool_use"]),
  content: z.string().nullable().optional(),
  metadata: z.record(z.string(), z.string()).nullable().optional(),
});

export const ExplorerFilePatchSchema = z.object({
  repo_name: z.string(),
  patch: z.object({
    path: z.string(),
    type: z.enum(["A", "M", "D"]),
    added: z.number(),
    removed: z.number(),
  }),
  diff: z.string().optional(),
});

export const MemoryBlockSchema = z.object({
  id: z.string(),
  message: MessageSchema,
  timestamp: z.string(),
  loading: z.boolean().optional(),
  artifacts: z.array(z.object({ key: z.string(), data: z.unknown(), reason: z.string().optional() })).optional(),
  file_patches: z.array(ExplorerFilePatchSchema).nullable().optional(),
  merged_file_patches: z.array(ExplorerFilePatchSchema).nullable().optional(),
}).passthrough();

export const PendingUserInputSchema = z.object({
  id: z.string(),
  input_type: z.string(),
  data: z.record(z.string(), z.unknown()),
});

export const RepoPRStateSchema = z.object({
  repo_name: z.string(),
  branch_name: z.string().nullable().optional(),
  pr_number: z.number().nullable().optional(),
  pr_url: z.string().nullable().optional(),
  pr_creation_status: z.enum(["creating", "completed", "error"]).nullable().optional(),
  title: z.string().nullable().optional(),
  description: z.string().nullable().optional(),
});

export const ExplorerStateSchema = z.object({
  run_id: z.number(),
  status: z.string(), // "processing" | "completed" | "error" | "awaiting_user_input"
  blocks: z.array(MemoryBlockSchema),
  updated_at: z.string(),
  pending_user_input: PendingUserInputSchema.nullable().optional(),
  repo_pr_states: z.record(z.string(), RepoPRStateSchema).optional(),
  coding_agents: z.record(z.string(), z.unknown()).optional(),
}).passthrough();

// Explorer status constants (lowercase, different from legacy)
export const EXPLORER_TERMINAL_STATUSES = ["completed", "error"] as const;
export function isExplorerTerminalStatus(status: string): boolean {
  return (EXPLORER_TERMINAL_STATUSES as readonly string[]).includes(status);
}

2. src/lib/api/seer.ts — Add Explorer API Functions

Add 4 new functions:

/** Trigger full autofix in explorer mode */
export async function triggerExplorerAutofix(
  orgSlug: string,
  issueId: string,
  stoppingPoint: StoppingPoint = "code_changes",
  instruction?: string
): Promise<{ run_id: number }> {
  const regionUrl = await resolveOrgRegion(orgSlug);
  const { data } = await apiRequestToRegion(
    regionUrl,
    `/organizations/${orgSlug}/issues/${issueId}/autofix/?mode=explorer`,
    {
      method: "POST",
      body: {
        step: "root_cause",
        stopping_point: stoppingPoint,
        ...(instruction ? { instruction } : {}),
      },
    }
  );
  return data as { run_id: number };
}

/** Get explorer autofix state */
export async function getExplorerAutofixState(
  orgSlug: string,
  issueId: string
): Promise<ExplorerState | null> {
  const regionUrl = await resolveOrgRegion(orgSlug);
  const { data } = await apiRequestToRegion(
    regionUrl,
    `/organizations/${orgSlug}/issues/${issueId}/autofix/?mode=explorer`,
    { method: "GET" }
  );
  const response = data as { autofix: unknown };
  return response.autofix ? ExplorerStateSchema.parse(response.autofix) : null;
}

/** Send a user message to continue the explorer conversation */
export async function sendExplorerMessage(
  orgSlug: string,
  runId: number,
  message: string
): Promise<{ run_id: number }> {
  const regionUrl = await resolveOrgRegion(orgSlug);
  const { data } = await apiRequestToRegion(
    regionUrl,
    `/organizations/${orgSlug}/seer/explorer-chat/${runId}/`,
    {
      method: "POST",
      body: { query: message },
    }
  );
  return data as { run_id: number };
}

/** Send an update to the explorer run (e.g., create PR) */
export async function sendExplorerUpdate(
  orgSlug: string,
  runId: number,
  payload: Record<string, unknown>
): Promise<unknown> {
  const regionUrl = await resolveOrgRegion(orgSlug);
  const { data } = await apiRequestToRegion(
    regionUrl,
    `/organizations/${orgSlug}/seer/explorer-update/${runId}/`,
    {
      method: "POST",
      body: payload,
    }
  );
  return data;
}

3. src/commands/issue/fix.ts — New Fix Command (main implementation)

The core interactive loop:

1. Resolve org + issue ID (reuse resolveOrgAndIssueId)
2. Trigger explorer autofix with stopping_point: "code_changes" (or "open_pr" if --pr)
3. Enter interactive loop:
   a. Poll getExplorerAutofixState until status changes
   b. If "awaiting_user_input" → display Seer's question, prompt user for response
   c. Send user's response via sendExplorerMessage
   d. Resume polling
   e. If "completed" → extract patches from blocks (merged_file_patches)
   f. If "error" → throw error
4. Present patches to user:
   a. Show diff summary (files changed, lines ±)
   b. Prompt: apply locally / create PR / both / skip
   c. If apply: use `git apply` on the diffs (or write files directly)
   d. If PR: send explorer-update with create_pr, poll for PR URL
5. Display final summary

Flags:

  • --json — Machine-readable output (non-interactive, emits final result)
  • --force — Force new fix even if one exists
  • --fresh — Bypass cache
  • --pr — After code changes, also create a PR (default: just show patches)
  • --apply — Apply patches to local working tree (default in human mode if cwd matches a repo)
  • --instruction — Custom instruction to guide the fix

Interactive prompt handling:

  • Use logger.prompt() (consola) for text input when Seer needs user input
  • Check isatty(0) for non-interactive detection: in non-interactive mode, skip user input prompts and let Seer proceed with defaults or error
  • Handle Symbol(clack:cancel) (Ctrl+C) gracefully — cancel the run or exit

Flow (human mode):

  1. Trigger explorer autofix with stopping_point: "code_changes"
  2. Poll with spinner, display progress from blocks
  3. When awaiting_user_input — stop spinner, show Seer's question, prompt user
  4. Send response via explorer-chat, resume polling
  5. On completion:
    a. Extract file patches/diffs from final state (merged_file_patches in blocks)
    b. Display diff summary (files changed, lines added/removed)
    c. Prompt: "Apply patches locally?" / "Create a PR?" / "Both?" / "Neither?"
    d. If apply: write patches to local files using git apply or direct file writes
    e. If PR: call explorer-update with { type: "create_pr" }, poll for PR URL
    f. Show final result

JSON mode (Phase 1 — non-interactive):

  • Use output: { json: true, human: formatFixResult } for the final result
  • Don't prompt — poll until completion, return final state
  • JSON output includes: { run_id, status, artifacts, patches: [...], pr_urls: [...] }
  • Patches include full diffs so agents can apply them programmatically

Human mode output:

  • Show spinner during processing (reuse poll() or spinner from polling.ts)
  • Display Seer's messages/blocks as they appear using renderMarkdown()
  • When awaiting input: stop spinner, show Seer's question, show prompt
  • On completion: show diff, prompt for action (apply/PR/both/skip)

4. src/lib/formatters/seer.ts — Add Explorer Formatters

Add formatting for:

  • formatExplorerBlock(block) — Format a memory block for human output
  • formatPendingInput(input) — Format Seer's question for the user
  • formatPRResult(prStates) — Format PR URLs and status
  • formatFixSummary(state) — Final summary with artifacts + PRs
  • getExplorerProgressMessage(state) — Extract progress from explorer blocks

5. src/commands/issue/index.ts — Register Fix Command

Add fix to the route map:

import { fixCommand } from "./fix.js";

export const issueRoute = buildRouteMap({
  routes: {
    list: listCommand,
    explain: explainCommand,
    plan: planCommand,
    view: viewCommand,
    fix: fixCommand,
  },
  docs: { ... }
});

Update the docs fullDescription to include fix in the commands list.

6. src/commands/issue/utils.ts — Add Explorer Polling

Add pollExplorerState() utility function mirroring pollAutofixState():

export async function pollExplorerState(options: {
  orgSlug: string;
  issueId: string;
  json: boolean;
  stopOnAwaitingInput?: boolean;
  pollIntervalMs?: number;
  timeoutMs?: number;
}): Promise<ExplorerState> {
  return poll<ExplorerState>({
    fetchState: () => getExplorerAutofixState(options.orgSlug, options.issueId),
    shouldStop: (state) => {
      if (isExplorerTerminalStatus(state.status)) return true;
      if (options.stopOnAwaitingInput && state.status === "awaiting_user_input") return true;
      return false;
    },
    getProgressMessage: getExplorerProgressMessage,
    json: options.json,
    pollIntervalMs: options.pollIntervalMs,
    timeoutMs: options.timeoutMs ?? 600_000, // 10 min for full fix (longer than explain/plan)
    timeoutMessage: "Fix timed out. Check the issue in Sentry web UI.",
    initialMessage: "Starting fix analysis...",
  });
}

7. src/lib/api-client.ts — Re-export New API Functions

Add re-exports for the new explorer API functions so they're accessible from the barrel import.

8. src/lib/patch.ts — Local Patch Application Utility

New utility for applying Seer's diffs to the local working tree:

/**
 * Apply a unified diff to the local working tree.
 * Tries `git apply` first (handles renames, binary, context),
 * falls back to manual file writes if git is unavailable.
 */
export async function applyPatch(diff: string, cwd: string): Promise<ApplyResult> {
  // 1. Try git apply --check first (dry run)
  // 2. If check passes, git apply
  // 3. Return { applied: string[], failed: string[] }
}

/**
 * Match a Seer repo_name to a local git remote.
 * Used to validate that patches target the right repo.
 */
export async function matchRepoToLocal(repoName: string, cwd: string): Promise<boolean> {
  // Check git remote -v for matching repo name
}

Implementation Order

  1. Types first (src/types/seer.ts) — Add explorer schemas
  2. API functions (src/lib/api/seer.ts) — Add 4 explorer API functions
  3. Re-exports (src/lib/api-client.ts) — Re-export new functions
  4. Patch utility (src/lib/patch.ts) — Local diff application
  5. Formatters (src/lib/formatters/seer.ts) — Add explorer formatting
  6. Utils (src/commands/issue/utils.ts) — Add pollExplorerState
  7. Fix command (src/commands/issue/fix.ts) — The main command
  8. Route map (src/commands/issue/index.ts) — Register fix
  9. Tests — Unit + property tests

Testing Strategy

  1. Unit tests (test/commands/issue/fix.test.ts):

    • Test the interactive loop with mocked API responses
    • Test JSON mode (non-interactive, final result)
    • Test non-interactive mode (no tty) — skips prompts
    • Test error handling (API errors → SeerError)
    • Test --pr flag (triggers PR creation)
    • Test --apply flag (applies patches locally)
  2. API function tests (test/lib/api/seer.test.ts):

    • Test explorer trigger, state fetch, message send, update
    • Test error wrapping
  3. Patch utility tests (test/lib/patch.test.ts):

    • Test applyPatch() with real diffs in a temp git repo
    • Test repo matching with various remote URL formats
    • Property test: generated diffs applied and unapplied round-trip
  4. Formatter tests (test/lib/formatters/seer.test.ts):

    • Test block formatting
    • Test pending input formatting
    • Test PR result formatting
    • Test diff summary formatting
  5. Type tests (test/types/seer.property.test.ts):

    • Validate ExplorerStateSchema parsing with real-shaped data
    • Edge cases: null blocks, missing fields, empty patches

Verification

After implementation:

  1. bun run typecheck — no type errors
  2. bun run lint — passes biome
  3. bun test — all tests pass
  4. Manual test: bun run dev -- issue fix @latest with a real Sentry account to verify the full interactive flow
  5. Manual test: bun run dev -- issue fix @latest --json to verify JSON mode
  6. Manual test: echo '{}' | bun run dev -- issue fix @latest --json for non-interactive JSON

Risk Assessment

  • Low risk: Types, formatters, API functions, route registration — standard patterns
  • Medium risk: The interactive loop is new territory for this CLI. Mitigated by:
    • Reusing poll() for the waiting phase
    • Using established consola.prompt() for user input
    • Clean separation: poll → prompt → send → poll cycle
  • Medium risk: Local patch application — git apply is well-tested but edge cases exist (binary files, renames, merge conflicts). Mitigated by:
    • Dry-run check (git apply --check) before actual apply
    • Clear error messages when patches fail
    • Fallback suggestion: "Open PR instead?"
  • Feature flag risk: Explorer mode requires organizations:seer-explorer feature flag on the backend. The CLI should handle 403/404 gracefully with a clear error message ("Explorer mode not available for your organization. Try the web UI instead.")
  • Phase 1 scope: Non-interactive JSON mode keeps initial scope manageable. NDJSON streaming for agent interactivity can be added later.

Metadata

Metadata

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions