-
-
Notifications
You must be signed in to change notification settings - Fork 2
Description
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--jsonmode, 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 withstopping_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):
- Trigger explorer autofix with
stopping_point: "code_changes" - Poll with spinner, display progress from blocks
- When
awaiting_user_input— stop spinner, show Seer's question, prompt user - Send response via explorer-chat, resume polling
- 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 usinggit applyor 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 outputformatPendingInput(input)— Format Seer's question for the userformatPRResult(prStates)— Format PR URLs and statusformatFixSummary(state)— Final summary with artifacts + PRsgetExplorerProgressMessage(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
- Types first (
src/types/seer.ts) — Add explorer schemas - API functions (
src/lib/api/seer.ts) — Add 4 explorer API functions - Re-exports (
src/lib/api-client.ts) — Re-export new functions - Patch utility (
src/lib/patch.ts) — Local diff application - Formatters (
src/lib/formatters/seer.ts) — Add explorer formatting - Utils (
src/commands/issue/utils.ts) — AddpollExplorerState - Fix command (
src/commands/issue/fix.ts) — The main command - Route map (
src/commands/issue/index.ts) — Registerfix - Tests — Unit + property tests
Testing Strategy
-
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
--prflag (triggers PR creation) - Test
--applyflag (applies patches locally)
-
API function tests (
test/lib/api/seer.test.ts):- Test explorer trigger, state fetch, message send, update
- Test error wrapping
-
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
- Test
-
Formatter tests (
test/lib/formatters/seer.test.ts):- Test block formatting
- Test pending input formatting
- Test PR result formatting
- Test diff summary formatting
-
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:
bun run typecheck— no type errorsbun run lint— passes biomebun test— all tests pass- Manual test:
bun run dev -- issue fix @latestwith a real Sentry account to verify the full interactive flow - Manual test:
bun run dev -- issue fix @latest --jsonto verify JSON mode - Manual test:
echo '{}' | bun run dev -- issue fix @latest --jsonfor 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
- Reusing
- Medium risk: Local patch application —
git applyis 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?"
- Dry-run check (
- Feature flag risk: Explorer mode requires
organizations:seer-explorerfeature 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.