diff --git a/extension/src/approval-handler.ts b/extension/src/approval-handler.ts new file mode 100644 index 0000000..03112f1 --- /dev/null +++ b/extension/src/approval-handler.ts @@ -0,0 +1,450 @@ +/** + * Approval Handler — response processing + approval execution pipeline. + * + * Extracted from step-probe.ts to reduce file size. + * Handles: + * - Response file watching (file-based bridge fallback) + * - Response processing (diff_review, DOM observer, step_probe paths) + * - Multi-strategy approval execution (RPC, VS Code commands, DOM click) + * - Diff review Accept/Reject via VS Code commands + */ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { BridgeContext } from './step-probe'; + +// ─── Module-level state (injected via initApprovalHandler) ─── + +let ctx: BridgeContext; +let responseWatcher: fs.FSWatcher | null = null; +let getTrajectoryId: () => string = () => ''; + +// ─── Public API ─── + +/** + * Initialize the approval handler with shared context. + * Called from initStepProbe() in step-probe.ts. + */ +export function initApprovalHandler( + context: BridgeContext, + trajectoryIdGetter: () => string, +) { + ctx = context; + getTrajectoryId = trajectoryIdGetter; +} + +/** + * Handle diff_review Accept all / Reject all response. + * Called from both WS onResponse (extension.ts) and processResponseFile. + * + * This was previously only in processResponseFile (file-bridge path). + * When WS was added (v0.4.x), the onResponse handler skipped this logic entirely, + * causing Accept All to stop working — a regression. + */ +export async function handleDiffReviewResponse(data: { + request_id: string; + approved: boolean; + button_index?: number; + step_type?: string; +}): Promise { + const btnIdx = data.button_index ?? -1; + const isAccept = btnIdx === 0 || (btnIdx === -1 && data.approved); + const cmd = isAccept + ? 'antigravity.prioritized.agentAcceptAllInFile' + : 'antigravity.prioritized.agentRejectAllInFile'; + ctx.logToFile(`[DIFF-REVIEW-WS] → ${isAccept ? 'ACCEPT' : 'REJECT'} (btnIdx=${btnIdx}, rid=${data.request_id?.substring(0, 12)})`); + + let diffReviewDone = false; + let modifiedFiles: string[] = []; + + // Load tracked step indices and modified files from memory cache or pending file + const trackedSteps: number[] = []; + const memMeta = ctx.diffReviewMetadata.get(data.request_id); + if (memMeta) { + trackedSteps.push(...memMeta.edit_step_indices); + modifiedFiles = memMeta.modified_files; + ctx.diffReviewMetadata.delete(data.request_id); + ctx.logToFile(`[DIFF-REVIEW-WS] loaded from memory: steps=[${trackedSteps.join(',')}] files=${modifiedFiles.length}`); + } else { + try { + const pf = path.join(ctx.bridgePath, 'pending', `${data.request_id}.json`); + if (fs.existsSync(pf)) { + const pd = JSON.parse(fs.readFileSync(pf, 'utf-8')); + if (pd.edit_step_indices) trackedSteps.push(...pd.edit_step_indices); + if (pd.modified_files) modifiedFiles = pd.modified_files; + } + } catch { } + } + + // Strategy 1: VS Code command — open review panel + focus each file + accept/reject + try { + try { + await vscode.commands.executeCommand('antigravity.openReviewChanges'); + ctx.logToFile(`[DIFF-REVIEW-WS] openReviewChanges OK`); + await new Promise(r => setTimeout(r, 500)); + } catch { } + + if (modifiedFiles.length > 0) { + for (const fp of modifiedFiles) { + try { + const uri = vscode.Uri.file(fp); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc, { preview: false }); + await new Promise(r => setTimeout(r, 300)); + await vscode.commands.executeCommand(cmd); + ctx.logToFile(`[DIFF-REVIEW-WS] ✅ ${cmd} on ${fp.split(/[\\/]/).pop()} OK`); + diffReviewDone = true; + } catch (e: any) { + ctx.logToFile(`[DIFF-REVIEW-WS] per-file error on ${fp}: ${e.message?.substring(0, 80)}`); + } + } + } else { + await vscode.commands.executeCommand(cmd); + ctx.logToFile(`[DIFF-REVIEW-WS] ✅ ${cmd} executed (no file list)`); + diffReviewDone = true; + } + } catch (cmdErr: any) { + ctx.logToFile(`[DIFF-REVIEW-WS] Strategy 1 command error: ${cmdErr.message?.substring(0, 200)}`); + } + + // Strategy 2: individual hunk accept/reject + if (!diffReviewDone) { + try { + const hunkCmd = isAccept + ? 'antigravity.prioritized.agentAcceptFocusedHunk' + : 'antigravity.prioritized.agentRejectFocusedHunk'; + await vscode.commands.executeCommand(hunkCmd); + ctx.logToFile(`[DIFF-REVIEW-WS] ✅ ${hunkCmd} fallback OK`); + diffReviewDone = true; + } catch (hunkErr: any) { + ctx.logToFile(`[DIFF-REVIEW-WS] hunk fallback error: ${hunkErr.message?.substring(0, 100)}`); + } + } + + if (!diffReviewDone) { + ctx.logToFile(`[DIFF-REVIEW-WS] ❌ ALL strategies failed for rid=${data.request_id}`); + } + return diffReviewDone; +} + +// ─── Response Watcher ─── + +export function setupResponseWatcher() { + const responseDir = path.join(ctx.bridgePath, 'response'); + if (!fs.existsSync(responseDir)) { + fs.mkdirSync(responseDir, { recursive: true }); + } + + const processAnyResponse = (filename: string) => { + const fp = path.join(responseDir, filename); + if (fs.existsSync(fp)) { + // Check if this response belongs to our project + const rid = filename.replace('.json', ''); + const pendingFile = path.join(ctx.bridgePath, 'pending', `${rid}.json`); + if (fs.existsSync(pendingFile)) { + try { + const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8')); + if (pending.project_name && pending.project_name !== ctx.projectName) { + return; // Not our project + } + } catch { } + } else { + // Pending file missing (deleted or auto_resolved) — check response data itself + try { + const respData = JSON.parse(fs.readFileSync(fp, 'utf-8')); + if (respData.project_name && respData.project_name !== ctx.projectName) { + return; + } + } catch { } + } + setTimeout(() => processResponseFile(fp), 300); + } + }; + + const pollAllResponses = () => { + try { + if (!fs.existsSync(responseDir)) return; + for (const f of fs.readdirSync(responseDir)) { + if (f.endsWith('.json')) { + processAnyResponse(f); + } + } + } catch { } + }; + + pollAllResponses(); // Process any existing responses on startup + + try { + responseWatcher = fs.watch(responseDir, (event, filename) => { + if (filename && filename.endsWith('.json') && event === 'rename') { + processAnyResponse(filename); + } + }); + console.log('Gravity Bridge: response watcher started'); + } catch (e: any) { + console.log(`Gravity Bridge: response watcher failed: ${e.message}`); + } + + // Polling fallback: fs.watch on Windows can silently fail + setInterval(pollAllResponses, 3000); +} + +// ─── Response File Processing ─── + +async function processResponseFile(filePath: string) { + try { + // Gracefully handle files already consumed by HTTP handler + if (!fs.existsSync(filePath)) { + return; + } + const content = fs.readFileSync(filePath, 'utf-8'); + const resp = JSON.parse(content); + const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved} step_type=${resp.step_type || '(missing)'} keys=[${Object.keys(resp).join(',')}]`; + console.log(`Gravity Bridge: ${msg}`); + ctx.logToFile(msg); + + // Skip stale timeout responses: if pending is old and this is a reject, it's likely a bot timeout + const ridTimestamp = parseInt((resp.request_id || '').split('_')[0], 10); + if (!isNaN(ridTimestamp)) { + const ageMs = Date.now() - ridTimestamp; + const STALE_THRESHOLD_MS = 120_000; // 2 minutes + if (ageMs > STALE_THRESHOLD_MS && !resp.approved) { + ctx.logToFile(`[RESPONSE] SKIPPED stale timeout: rid=${resp.request_id} age=${Math.round(ageMs / 1000)}s (>${STALE_THRESHOLD_MS / 1000}s, reject)`); + try { fs.unlinkSync(filePath); } catch { } + return; + } + } + + // Find matching pending request + const pendingDir = path.join(ctx.bridgePath, 'pending'); + const pendingFile = path.join(pendingDir, `${resp.request_id}.json`); + let sessionId = ''; + let isDomObserver = false; + let pendingStepType = resp.step_type || ''; // from bot's response (new) + let pendingStepIndex = -1; + if (fs.existsSync(pendingFile)) { + try { + const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8')); + + // FIX #2: Skip if pending was already resolved locally (auto_resolve or expired) + if (pending.status === 'auto_resolved' || pending.status === 'expired') { + ctx.logToFile(`[RESPONSE] SKIP — pending already ${pending.status} (rid=${resp.request_id})`); + try { fs.unlinkSync(filePath); } catch { } + return; + } + + sessionId = pending.conversation_id || ''; + isDomObserver = pending.auto_detected === true + || pending.source === 'dom_observer'; + pendingStepType = pending.step_type || ''; + pendingStepIndex = pending.step_index ?? ctx.lastPendingStepIndex; + // File permission detection: check command content or explicit step_type + const cmd = (pending.command || '').toLowerCase(); + if (pendingStepType === 'file_permission' || cmd.includes('allow') || cmd.includes('파일 접근')) { + // Map button_index → scope: 0=Once, 1=Conversation, 2=Deny + const btnIdx = resp.button_index ?? -1; + if (btnIdx === 1) { + pendingStepType = 'file_permission_conversation'; + } else if (btnIdx === 2) { + pendingStepType = 'file_permission_deny'; + } else { + pendingStepType = 'file_permission_once'; + } + ctx.logToFile(`[RESPONSE] file_permission detected from pending cmd, mapped to ${pendingStepType}`); + } + } catch { } + } + + // ═══ MULTI-STRATEGY APPROVAL (v2.1) ═══ + const approved = resp.approved; + + // ── diff_review: Accept all / Reject all ── + if (pendingStepType === 'diff_review') { + // Delegate to shared handler (also used by WS onResponse path in extension.ts) + await handleDiffReviewResponse({ + request_id: resp.request_id, + approved, + button_index: resp.button_index, + step_type: pendingStepType, + }); + } else if (isDomObserver) { + // DOM observer path: ALSO try RPC strategies (renderer click is unreliable) + const targetSession = sessionId || ctx.activeSessionId; + ctx.logToFile(`[RESPONSE] dom_observer → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`); + const strategyResult = await tryApprovalStrategies(approved, targetSession, pendingStepType, pendingStepIndex); + ctx.logToFile(`[RESPONSE] dom strategy result: ${strategyResult}`); + } else { + // Step probe path: run ALL approval strategies + const targetSession = sessionId || ctx.activeSessionId; + ctx.logToFile(`[RESPONSE] step_probe → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`); + const strategyResult = await tryApprovalStrategies(approved, targetSession, pendingStepType, pendingStepIndex); + ctx.logToFile(`[RESPONSE] strategy result: ${strategyResult}`); + } + + ctx.logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`); + + // FIX v2 (2026-03-16): Correct state management after response processing. + // Set ctx.sawRunningAfterPending=true to close the auto_resolve gate. + ctx.sawRunningAfterPending = true; + + // Cleanup response file + // CRITICAL: DOM observer responses must NOT be deleted here! + if (!isDomObserver) { + try { fs.unlinkSync(filePath); } catch { } + } + } catch (e: any) { + const log = `[RESPONSE] error: ${e.message}`; + console.log(`Gravity Bridge: ${log}`); + ctx.logToFile(log); + } +} + +// ─── Approval Strategies ─── + +/** + * Try multiple approval methods sequentially. + * Returns a string describing which method succeeded (or all failed). + * + * Strategy order (most reliable first): + * 1. HandleCascadeUserInteraction RPC (cross-platform, no focus) + * 2. VS Code accept/reject commands (focus-dependent) + * 3. Log failure for manual intervention + */ +export async function tryApprovalStrategies(approved: boolean, sessionId: string, stepType: string = '', stepIndex: number = -1): Promise { + const action = approved ? 'APPROVE' : 'REJECT'; + const effectiveStepIndex = stepIndex >= 0 ? stepIndex : ctx.lastPendingStepIndex; + ctx.logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${effectiveStepIndex}`); + + // ── Dynamic Command Discovery (log what's available during WAITING state) ── + let approvalCmdList: string[] = []; + try { + const allCmds = await vscode.commands.getCommands(true); + const agCmds = allCmds.filter((c: string) => c.startsWith('antigravity.')); + approvalCmdList = agCmds.filter((c: string) => { + const lower = c.toLowerCase(); + return lower.includes('accept') || lower.includes('reject') || lower.includes('approve') + || lower.includes('terminal') || lower.includes('run') || lower.includes('step') + || lower.includes('cascade') || lower.includes('action'); + }); + ctx.logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmdList.length} approval-related:`); + for (const c of approvalCmdList) { + ctx.logToFile(`[APPROVAL-CMD-CHECK] → ${c}`); + } + } catch (e: any) { + ctx.logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`); + } + + // ══════════════════════════════════════════════════════════ + // STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source) + // ══════════════════════════════════════════════════════════ + if (ctx.sdk && approved) { + // Build interaction sub-message based on step_type + const typeLower = stepType.toLowerCase().replace('cortex_step_type_', ''); + let interactionPayload: Record = {}; + + if (typeLower.includes('code_edit') || typeLower.includes('write_to_file') || typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit')) { + // CODE EDIT: Uses acknowledgeCodeActionStep RPC (correct AG LS method) + try { + ctx.logToFile(`[APPROVAL-CODE-EDIT] trying submitCodeAcknowledgement command`); + await vscode.commands.executeCommand('antigravity.prioritized.submitCodeAcknowledgement'); + ctx.logToFile(`[APPROVAL-CODE-EDIT] ✅ submitCodeAcknowledgement OK`); + return `CMD:submitCodeAcknowledgement(accept=${approved})`; + } catch { + ctx.logToFile(`[APPROVAL-CODE-EDIT] submitCodeAcknowledgement not available, trying RPC`); + } + // Direct LS RPC with correct method name + try { + ctx.logToFile(`[APPROVAL-CODE-EDIT] acknowledgeCodeActionStep(cascadeId=${sessionId.substring(0, 8)}, accept=${approved}, stepIndices=[${effectiveStepIndex}])`); + const ackResult = await ctx.sdk.ls.rawRPC('acknowledgeCodeActionStep', { + cascadeId: sessionId, + accept: approved, + stepIndices: [effectiveStepIndex], + }); + ctx.logToFile(`[APPROVAL-CODE-EDIT] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`); + return `RPC:acknowledgeCodeActionStep(accept=${approved})`; + } catch (e: any) { + ctx.logToFile(`[APPROVAL-CODE-EDIT] ❌ ${e.message.substring(0, 200)}`); + ctx.logToFile(`[APPROVAL-CODE-EDIT] falling back to HandleCascadeUserInteraction`); + interactionPayload = { runCommand: { confirm: true } }; + } + } + + // Map step_type to interaction sub-message field + if (typeLower.includes('run_command') || typeLower.includes('shell_exec')) { + interactionPayload = { runCommand: { confirm: true } }; + } else if (typeLower.includes('open_browser')) { + interactionPayload = { openBrowserUrl: { confirm: true } }; + } else if (typeLower.includes('send_command_input')) { + interactionPayload = { sendCommandInput: { confirm: true } }; + } else if (typeLower.includes('read_url')) { + interactionPayload = { readUrlContent: { confirm: true } }; + } else if (typeLower.includes('mcp')) { + interactionPayload = { mcpTool: { confirm: true } }; + } else if (typeLower.includes('invoke_subagent') || typeLower.includes('extension_code')) { + interactionPayload = { runExtensionCode: { confirm: true } }; + } else if (typeLower.includes('file_permission')) { + const scope = typeLower.includes('conversation') ? 2 : 1; + interactionPayload = { filePermission: { allow: true, scope } }; + } else if (typeLower.includes('elicitation')) { + interactionPayload = { elicitation: {} }; + } else { + // Default: try run_command (most common) + interactionPayload = { runCommand: { confirm: true } }; + } + + const activeTrajectoryId = getTrajectoryId(); + const protoVariants = [ + // Variant A: camelCase with trajectoryId (proven working for run_command) + { + cascadeId: sessionId, + interaction: { + trajectoryId: activeTrajectoryId || sessionId, + stepIndex: effectiveStepIndex, + ...interactionPayload, + }, + }, + // Variant B: snake_case + { + cascade_id: sessionId, + interaction: { + trajectory_id: activeTrajectoryId || sessionId, + step_index: effectiveStepIndex, + ...interactionPayload, + }, + }, + // Variant C: minimal (no trajectoryId) + { + cascadeId: sessionId, + interaction: { + stepIndex: effectiveStepIndex, + ...interactionPayload, + }, + }, + ]; + for (let i = 0; i < protoVariants.length; i++) { + try { + const payload = protoVariants[i]; + ctx.logToFile(`[APPROVAL-PROTO-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`); + const rpcResult = await ctx.sdk.ls.rawRPC('HandleCascadeUserInteraction', payload); + ctx.logToFile(`[APPROVAL-PROTO-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`); + return `RPC-PROTO-${i}:HandleCascadeUserInteraction(${typeLower})`; + } catch (e: any) { + ctx.logToFile(`[APPROVAL-PROTO-${i}] ❌ ${e.message.substring(0, 300)}`); + } + } + } + + // ── Strategy 2: Renderer DOM Click via HTTP Bridge (primary fallback) ── + try { + const triggerAction = approved ? 'approve' : 'reject'; + ctx.logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`); + ctx.setClickTrigger(triggerAction as 'approve' | 'reject'); + ctx.logToFile(`[APPROVAL-2] ✅ clickTrigger set — renderer will poll and click within 2s`); + } catch (e: any) { + ctx.logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`); + } + + ctx.logToFile(`[APPROVAL] strategies complete — check logs for results`); + return `STRATEGIES_DONE:${action}`; +} diff --git a/extension/src/step-probe.ts b/extension/src/step-probe.ts index 61da069..8108af0 100644 --- a/extension/src/step-probe.ts +++ b/extension/src/step-probe.ts @@ -8,6 +8,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { WSBridgeClient } from './ws-client'; import { extractPlannerText, extractToolCommand, extractToolDescription } from './step-utils'; +import { initApprovalHandler, setupResponseWatcher } from './approval-handler'; + +// Re-export from approval-handler for backward compatibility with extension.ts imports +export { handleDiffReviewResponse, tryApprovalStrategies } from './approval-handler'; export interface BridgeContext { bridgePath: string; @@ -35,10 +39,7 @@ const recentPendingSteps = new Map(); const PENDING_MEMORY_TTL_MS = 60_000; // generateApprovalObserverScript → extracted to ./observer-script.ts -// Track last seen step per session to avoid re-fetching -const lastSeenStep = new Map(); const lastSnapshotText = new Map(); -const registeredSessions = new Set(); // track which sessions have been registered /** * Get current approval context for WS response routing. @@ -84,99 +85,7 @@ export function resetPendingState(): void { ctx.sawRunningAfterPending = false; } -/** - * Handle diff_review Accept all / Reject all response. - * Extracted so both WS onResponse (extension.ts) and processResponseFile can call it. - * - * This was previously only in processResponseFile (file-bridge path). - * When WS was added (v0.4.x), the onResponse handler skipped this logic entirely, - * causing Accept All to stop working — a regression. - */ -export async function handleDiffReviewResponse(data: { - request_id: string; - approved: boolean; - button_index?: number; - step_type?: string; -}): Promise { - const btnIdx = data.button_index ?? -1; - const isAccept = btnIdx === 0 || (btnIdx === -1 && data.approved); - const cmd = isAccept - ? 'antigravity.prioritized.agentAcceptAllInFile' - : 'antigravity.prioritized.agentRejectAllInFile'; - ctx.logToFile(`[DIFF-REVIEW-WS] → ${isAccept ? 'ACCEPT' : 'REJECT'} (btnIdx=${btnIdx}, rid=${data.request_id?.substring(0, 12)})`); - - let diffReviewDone = false; - let modifiedFiles: string[] = []; - - // Load tracked step indices and modified files from memory cache or pending file - const trackedSteps: number[] = []; - const memMeta = ctx.diffReviewMetadata.get(data.request_id); - if (memMeta) { - trackedSteps.push(...memMeta.edit_step_indices); - modifiedFiles = memMeta.modified_files; - ctx.diffReviewMetadata.delete(data.request_id); - ctx.logToFile(`[DIFF-REVIEW-WS] loaded from memory: steps=[${trackedSteps.join(',')}] files=${modifiedFiles.length}`); - } else { - try { - const pf = path.join(ctx.bridgePath, 'pending', `${data.request_id}.json`); - if (fs.existsSync(pf)) { - const pd = JSON.parse(fs.readFileSync(pf, 'utf-8')); - if (pd.edit_step_indices) trackedSteps.push(...pd.edit_step_indices); - if (pd.modified_files) modifiedFiles = pd.modified_files; - } - } catch { } - } - - // Strategy 1: VS Code command — open review panel + focus each file + accept/reject - try { - try { - await vscode.commands.executeCommand('antigravity.openReviewChanges'); - ctx.logToFile(`[DIFF-REVIEW-WS] openReviewChanges OK`); - await new Promise(r => setTimeout(r, 500)); - } catch { } - - if (modifiedFiles.length > 0) { - for (const fp of modifiedFiles) { - try { - const uri = vscode.Uri.file(fp); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc, { preview: false }); - await new Promise(r => setTimeout(r, 300)); - await vscode.commands.executeCommand(cmd); - ctx.logToFile(`[DIFF-REVIEW-WS] ✅ ${cmd} on ${fp.split(/[\\/]/).pop()} OK`); - diffReviewDone = true; - } catch (e: any) { - ctx.logToFile(`[DIFF-REVIEW-WS] per-file error on ${fp}: ${e.message?.substring(0, 80)}`); - } - } - } else { - await vscode.commands.executeCommand(cmd); - ctx.logToFile(`[DIFF-REVIEW-WS] ✅ ${cmd} executed (no file list)`); - diffReviewDone = true; - } - } catch (cmdErr: any) { - ctx.logToFile(`[DIFF-REVIEW-WS] Strategy 1 command error: ${cmdErr.message?.substring(0, 200)}`); - } - - // Strategy 2: individual hunk accept/reject - if (!diffReviewDone) { - try { - const hunkCmd = isAccept - ? 'antigravity.prioritized.agentAcceptFocusedHunk' - : 'antigravity.prioritized.agentRejectFocusedHunk'; - await vscode.commands.executeCommand(hunkCmd); - ctx.logToFile(`[DIFF-REVIEW-WS] ✅ ${hunkCmd} fallback OK`); - diffReviewDone = true; - } catch (hunkErr: any) { - ctx.logToFile(`[DIFF-REVIEW-WS] hunk fallback error: ${hunkErr.message?.substring(0, 100)}`); - } - } - - if (!diffReviewDone) { - ctx.logToFile(`[DIFF-REVIEW-WS] ❌ ALL strategies failed for rid=${data.request_id}`); - } - return diffReviewDone; -} +// handleDiffReviewResponse → moved to ./approval-handler.ts /** * Write a registration file for the Bot to discover session → project mapping. @@ -993,278 +902,9 @@ function setupMonitor() { // ─── Response Watcher (Discord approval → Antigravity RPC) ─── -function setupResponseWatcher() { - const responseDir = path.join(ctx.bridgePath, 'response'); - if (!fs.existsSync(responseDir)) { - fs.mkdirSync(responseDir, { recursive: true }); - } +// setupResponseWatcher → moved to ./approval-handler.ts - const processAnyResponse = (filename: string) => { - const fp = path.join(responseDir, filename); - if (fs.existsSync(fp)) { - // Check if this response belongs to our project - const rid = filename.replace('.json', ''); - const pendingFile = path.join(ctx.bridgePath, 'pending', `${rid}.json`); - if (fs.existsSync(pendingFile)) { - try { - const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8')); - if (pending.project_name && pending.project_name !== ctx.projectName) { - // ctx.logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${ctx.projectName})`); - return; // Not our project - } - } catch { } - } else { - // Pending file missing (deleted or auto_resolved) — check response data itself - try { - const respData = JSON.parse(fs.readFileSync(fp, 'utf-8')); - if (respData.project_name && respData.project_name !== ctx.projectName) { - // ctx.logToFile(`[RESPONSE] skip (from resp data) ${rid} (project=${respData.project_name}, we=${ctx.projectName})`); - return; - } - } catch { } - } - setTimeout(() => processResponseFile(fp), 300); - } - }; - - const pollAllResponses = () => { - try { - if (!fs.existsSync(responseDir)) return; - for (const f of fs.readdirSync(responseDir)) { - if (f.endsWith('.json')) { - processAnyResponse(f); - } - } - } catch { } - }; - - pollAllResponses(); // Process any existing responses on startup - - try { - responseWatcher = fs.watch(responseDir, (event, filename) => { - if (filename && filename.endsWith('.json') && event === 'rename') { - processAnyResponse(filename); - } - }); - console.log('Gravity Bridge: response watcher started'); - } catch (e: any) { - console.log(`Gravity Bridge: response watcher failed: ${e.message}`); - } - - // Polling fallback: fs.watch on Windows can silently fail - setInterval(pollAllResponses, 3000); -} - -async function processResponseFile(filePath: string) { - try { - // Gracefully handle files already consumed by HTTP handler - if (!fs.existsSync(filePath)) { - // HTTP GET /response/:rid already served and deleted this file — skip silently - return; - } - const content = fs.readFileSync(filePath, 'utf-8'); - const resp = JSON.parse(content); - const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved} step_type=${resp.step_type || '(missing)'} keys=[${Object.keys(resp).join(',')}]`; - console.log(`Gravity Bridge: ${msg}`); - ctx.logToFile(msg); - - // Skip stale timeout responses: if pending is old and this is a reject, it's likely a bot timeout - const ridTimestamp = parseInt((resp.request_id || '').split('_')[0], 10); - if (!isNaN(ridTimestamp)) { - const ageMs = Date.now() - ridTimestamp; - const STALE_THRESHOLD_MS = 120_000; // 2 minutes - if (ageMs > STALE_THRESHOLD_MS && !resp.approved) { - ctx.logToFile(`[RESPONSE] SKIPPED stale timeout: rid=${resp.request_id} age=${Math.round(ageMs / 1000)}s (>${STALE_THRESHOLD_MS / 1000}s, reject)`); - try { fs.unlinkSync(filePath); } catch { } - return; - } - } - - // Find matching pending request - const pendingDir = path.join(ctx.bridgePath, 'pending'); - const pendingFile = path.join(pendingDir, `${resp.request_id}.json`); - let sessionId = ''; - let isDomObserver = false; - let pendingStepType = resp.step_type || ''; // from bot's response (new) - let pendingStepIndex = -1; - if (fs.existsSync(pendingFile)) { - try { - const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8')); - - // FIX #2: Skip if pending was already resolved locally (auto_resolve or expired) - if (pending.status === 'auto_resolved' || pending.status === 'expired') { - ctx.logToFile(`[RESPONSE] SKIP — pending already ${pending.status} (rid=${resp.request_id})`); - try { fs.unlinkSync(filePath); } catch { } - return; - } - - sessionId = pending.conversation_id || ''; - isDomObserver = pending.auto_detected === true - || pending.source === 'dom_observer'; - pendingStepType = pending.step_type || ''; - pendingStepIndex = pending.step_index ?? ctx.lastPendingStepIndex; - // File permission detection: check command content or explicit step_type - const cmd = (pending.command || '').toLowerCase(); - if (pendingStepType === 'file_permission' || cmd.includes('allow') || cmd.includes('파일 접근')) { - // Map button_index → scope: 0=Once, 1=Conversation, 2=Deny - const btnIdx = resp.button_index ?? -1; - if (btnIdx === 1) { - pendingStepType = 'file_permission_conversation'; - } else if (btnIdx === 2) { - pendingStepType = 'file_permission_deny'; - } else { - pendingStepType = 'file_permission_once'; - } - ctx.logToFile(`[RESPONSE] file_permission detected from pending cmd, mapped to ${pendingStepType}`); - } - } catch { } - } - - // ═══ MULTI-STRATEGY APPROVAL (v2.1) ═══ - // Tries multiple methods sequentially with detailed logging. - // DOM observer: renderer handles clicking via pollResponse - // Step probe/stall: try RPC → VS Code commands → log results - - const approved = resp.approved; - - // ── diff_review: Accept all / Reject all ── - if (pendingStepType === 'diff_review') { - const btnIdx = resp.button_index ?? -1; - const isAccept = btnIdx === 0 || (btnIdx === -1 && approved); - const cmd = isAccept - ? 'antigravity.prioritized.agentAcceptAllInFile' - : 'antigravity.prioritized.agentRejectAllInFile'; - ctx.logToFile(`[RESPONSE] diff_review → ${isAccept ? 'ACCEPT' : 'REJECT'} (btnIdx=${btnIdx})`); - - let diffReviewDone = false; - const targetSession = sessionId || ctx.activeSessionId; - let modifiedFiles: string[] = []; - - // Load tracked step indices and modified files from memory cache or pending file - const trackedSteps: number[] = []; - const memMeta = ctx.diffReviewMetadata.get(resp.request_id); - if (memMeta) { - trackedSteps.push(...memMeta.edit_step_indices); - modifiedFiles = memMeta.modified_files; - ctx.diffReviewMetadata.delete(resp.request_id); - ctx.logToFile(`[DIFF-REVIEW] loaded from memory: steps=[${trackedSteps.join(',')}] files=${modifiedFiles.length}`); - } else { - try { - const pf = path.join(ctx.bridgePath, 'pending', `${resp.request_id}.json`); - if (fs.existsSync(pf)) { - const pd = JSON.parse(fs.readFileSync(pf, 'utf-8')); - if (pd.edit_step_indices) trackedSteps.push(...pd.edit_step_indices); - if (pd.modified_files) modifiedFiles = pd.modified_files; - } - } catch { } - } - if (trackedSteps.length === 0 && pendingStepIndex > 0) { - trackedSteps.push(pendingStepIndex); - } - - // ── Strategy 1: VS Code command (confirmed registered at runtime) ── - // agentAcceptAllInFile / agentRejectAllInFile are the ONLY working diff_review - // commands. RPC methods (acknowledgeCodeActionStep → 404, AcknowledgeCascadeCodeEdit - // → no-op {}, submitCodeAcknowledgement → not registered) are all dead ends. - // The command requires the diff review file to be focused in the editor. - try { - // First, open the Review Changes panel to ensure diff UI is active - try { - await vscode.commands.executeCommand('antigravity.openReviewChanges'); - ctx.logToFile(`[DIFF-REVIEW] openReviewChanges OK`); - await new Promise(r => setTimeout(r, 500)); - } catch { } - - if (modifiedFiles.length > 0) { - // Focus each modified file and execute accept/reject - for (const fp of modifiedFiles) { - try { - const uri = vscode.Uri.file(fp); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc, { preview: false }); - await new Promise(r => setTimeout(r, 300)); - await vscode.commands.executeCommand(cmd); - ctx.logToFile(`[DIFF-REVIEW] ✅ ${cmd} on ${fp.split(/[\\/]/).pop()} OK`); - diffReviewDone = true; - } catch (e: any) { - ctx.logToFile(`[DIFF-REVIEW] per-file error on ${fp}: ${e.message?.substring(0, 80)}`); - } - } - } else { - // No file list — just execute command (best effort on currently focused file) - await vscode.commands.executeCommand(cmd); - ctx.logToFile(`[DIFF-REVIEW] ✅ ${cmd} executed (no file list)`); - diffReviewDone = true; - } - } catch (cmdErr: any) { - ctx.logToFile(`[DIFF-REVIEW] Strategy 1 command error: ${cmdErr.message?.substring(0, 200)}`); - } - - // ── Strategy 2: Try individual hunk accept/reject as fallback ── - if (!diffReviewDone) { - try { - const hunkCmd = isAccept - ? 'antigravity.prioritized.agentAcceptFocusedHunk' - : 'antigravity.prioritized.agentRejectFocusedHunk'; - await vscode.commands.executeCommand(hunkCmd); - ctx.logToFile(`[DIFF-REVIEW] ✅ ${hunkCmd} fallback OK`); - diffReviewDone = true; - } catch (hunkErr: any) { - ctx.logToFile(`[DIFF-REVIEW] hunk fallback error: ${hunkErr.message?.substring(0, 100)}`); - } - } - - if (!diffReviewDone) { - ctx.logToFile(`[DIFF-REVIEW] ❌ ALL strategies failed for rid=${resp.request_id}`); - } - } else if (isDomObserver) { - // DOM observer path: ALSO try RPC strategies (renderer click is unreliable) - // Use sessionId from pending file if available, fallback to ctx.activeSessionId - const targetSession = sessionId || ctx.activeSessionId; - ctx.logToFile(`[RESPONSE] dom_observer → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`); - const strategyResult = await tryApprovalStrategies(approved, targetSession, pendingStepType, pendingStepIndex); - ctx.logToFile(`[RESPONSE] dom strategy result: ${strategyResult}`); - } else { - // Step probe path: run ALL approval strategies - // Use sessionId from pending file if available, fallback to ctx.activeSessionId - const targetSession = sessionId || ctx.activeSessionId; - ctx.logToFile(`[RESPONSE] step_probe → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`); - const strategyResult = await tryApprovalStrategies(approved, targetSession, pendingStepType, pendingStepIndex); - ctx.logToFile(`[RESPONSE] strategy result: ${strategyResult}`); - } - - ctx.logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`); - - // FIX v2 (2026-03-16): Correct state management after response processing. - // - // HISTORY: processResponseFile originally reset ctx.lastPendingStepIndex=-1 and ctx.stallProbed=false. - // v0.3.11 removed both resets entirely → caused infinite pending loop (step_probe re-detects - // same WAITING step because ctx.lastPendingStepIndex=-1 makes si!=ctx.lastPendingStepIndex true). - // v0.3.12 attempt removed resets entirely → conflicts with known-issues.md L479 - // (auto_resolve duplicate notification on delta>0 because ctx.sawRunningAfterPending is false). - // - // CORRECT FIX: Set ctx.sawRunningAfterPending=true to close the auto_resolve gate. - // - ctx.lastPendingStepIndex: KEEP (prevents step_probe from re-creating pending for same step) - // - ctx.stallProbed: KEEP (prevents re-probe during same stall) - // - ctx.sawRunningAfterPending = true: CRITICAL — tells auto_resolve "Discord handled this, - // don't generate duplicate 직접 진행됨 notification" (prevents known-issues L479 regression) - // All three reset naturally at delta > 0 (L1972-1980) when step actually progresses. - ctx.sawRunningAfterPending = true; - - // Cleanup response file - // CRITICAL: DOM observer responses must NOT be deleted here! - // The renderer polls GET /response/:rid to discover the approval. - // If we delete the file before the renderer polls, it gets ENOENT. - // The HTTP handler (/response/:rid) deletes after serving to renderer. - if (!isDomObserver) { - try { fs.unlinkSync(filePath); } catch { } - } - } catch (e: any) { - const log = `[RESPONSE] error: ${e.message}`; - console.log(`Gravity Bridge: ${log}`); - ctx.logToFile(log); - } -} +// processResponseFile → moved to ./approval-handler.ts /** * Extract AI text from a PLANNER_RESPONSE step. @@ -1418,169 +1058,7 @@ export function writePendingApproval(data: { conversation_id: string; command: s } } -// ─── Multi-Strategy Approval Execution ─── - -/** - * Try multiple approval methods sequentially. - * Returns a string describing which method succeeded (or all failed). - * - * Strategy order (most reliable first): - * 1. HandleCascadeUserInteraction RPC (cross-platform, no focus) - * 2. VS Code accept/reject commands (focus-dependent) - * 3. Log failure for manual intervention - */ -export async function tryApprovalStrategies(approved: boolean, sessionId: string, stepType: string = '', stepIndex: number = -1): Promise { - const action = approved ? 'APPROVE' : 'REJECT'; - const effectiveStepIndex = stepIndex >= 0 ? stepIndex : ctx.lastPendingStepIndex; - ctx.logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${effectiveStepIndex}`); - - // ── Dynamic Command Discovery (log what's available during WAITING state) ── - let approvalCmdList: string[] = []; - try { - const allCmds = await vscode.commands.getCommands(true); - const agCmds = allCmds.filter((c: string) => c.startsWith('antigravity.')); - approvalCmdList = agCmds.filter((c: string) => { - const lower = c.toLowerCase(); - return lower.includes('accept') || lower.includes('reject') || lower.includes('approve') - || lower.includes('terminal') || lower.includes('run') || lower.includes('step') - || lower.includes('cascade') || lower.includes('action'); - }); - ctx.logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmdList.length} approval-related:`); - for (const c of approvalCmdList) { - ctx.logToFile(`[APPROVAL-CMD-CHECK] → ${c}`); - } - } catch (e: any) { - ctx.logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`); - } - - // ══════════════════════════════════════════════════════════ - // STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source) - // Routes interaction sub-message by step_type: - // run_command → CascadeRunCommandInteraction { confirm } - // write_to_file → AcknowledgeCascadeCodeEdit RPC (separate) - // open_browser_url → CascadeOpenBrowserUrlInteraction { confirm } - // send_command_input → CascadeSendCommandInputInteraction { confirm } - // read_url_content → CascadeReadUrlContentInteraction { confirm } - // mcp_tool → CascadeMcpInteraction { confirm } - // invoke_subagent → CascadeRunExtensionCodeInteraction { confirm } - // ══════════════════════════════════════════════════════════ - if (ctx.sdk && approved) { - // Build interaction sub-message based on step_type - const typeLower = stepType.toLowerCase().replace('cortex_step_type_', ''); - let interactionPayload: Record = {}; - - if (typeLower.includes('code_edit') || typeLower.includes('write_to_file') || typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit')) { - // CODE EDIT: Uses acknowledgeCodeActionStep RPC (correct AG LS method) - // Try VS Code command first (same path as UI Accept all button) - try { - ctx.logToFile(`[APPROVAL-CODE-EDIT] trying submitCodeAcknowledgement command`); - await vscode.commands.executeCommand('antigravity.prioritized.submitCodeAcknowledgement'); - ctx.logToFile(`[APPROVAL-CODE-EDIT] ✅ submitCodeAcknowledgement OK`); - return `CMD:submitCodeAcknowledgement(accept=${approved})`; - } catch { - ctx.logToFile(`[APPROVAL-CODE-EDIT] submitCodeAcknowledgement not available, trying RPC`); - } - // Direct LS RPC with correct method name - try { - ctx.logToFile(`[APPROVAL-CODE-EDIT] acknowledgeCodeActionStep(cascadeId=${sessionId.substring(0, 8)}, accept=${approved}, stepIndices=[${effectiveStepIndex}])`); - const ackResult = await ctx.sdk.ls.rawRPC('acknowledgeCodeActionStep', { - cascadeId: sessionId, - accept: approved, - stepIndices: [effectiveStepIndex], - }); - ctx.logToFile(`[APPROVAL-CODE-EDIT] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`); - return `RPC:acknowledgeCodeActionStep(accept=${approved})`; - } catch (e: any) { - ctx.logToFile(`[APPROVAL-CODE-EDIT] ❌ ${e.message.substring(0, 200)}`); - // Fallback: try HandleCascadeUserInteraction with runCommand - ctx.logToFile(`[APPROVAL-CODE-EDIT] falling back to HandleCascadeUserInteraction`); - interactionPayload = { runCommand: { confirm: true } }; - } - } - - // Map step_type to interaction sub-message field - if (typeLower.includes('run_command') || typeLower.includes('shell_exec')) { - interactionPayload = { runCommand: { confirm: true } }; - } else if (typeLower.includes('open_browser')) { - interactionPayload = { openBrowserUrl: { confirm: true } }; - } else if (typeLower.includes('send_command_input')) { - interactionPayload = { sendCommandInput: { confirm: true } }; // guess field name - } else if (typeLower.includes('read_url')) { - interactionPayload = { readUrlContent: { confirm: true } }; // guess - } else if (typeLower.includes('mcp')) { - interactionPayload = { mcpTool: { confirm: true } }; // guess - } else if (typeLower.includes('invoke_subagent') || typeLower.includes('extension_code')) { - interactionPayload = { runExtensionCode: { confirm: true } }; - } else if (typeLower.includes('file_permission')) { - // FilePermissionInteraction: allow=true, scope depends on cmd - // file_permission_once → 1, file_permission_conversation → 2 - const scope = typeLower.includes('conversation') ? 2 : 1; - interactionPayload = { filePermission: { allow: true, scope } }; - } else if (typeLower.includes('elicitation')) { - interactionPayload = { elicitation: {} }; // ElicitationInteraction (TBD) - } else { - // Default: try run_command (most common) - interactionPayload = { runCommand: { confirm: true } }; - } - - const protoVariants = [ - // Variant A: camelCase with trajectoryId (proven working for run_command) - { - cascadeId: sessionId, - interaction: { - trajectoryId: activeTrajectoryId || sessionId, - stepIndex: effectiveStepIndex, - ...interactionPayload, - }, - }, - // Variant B: snake_case - { - cascade_id: sessionId, - interaction: { - trajectory_id: activeTrajectoryId || sessionId, - step_index: effectiveStepIndex, - ...interactionPayload, - }, - }, - // Variant C: minimal (no trajectoryId) - { - cascadeId: sessionId, - interaction: { - stepIndex: effectiveStepIndex, - ...interactionPayload, - }, - }, - ]; - for (let i = 0; i < protoVariants.length; i++) { - try { - const payload = protoVariants[i]; - ctx.logToFile(`[APPROVAL-PROTO-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`); - const rpcResult = await ctx.sdk.ls.rawRPC('HandleCascadeUserInteraction', payload); - ctx.logToFile(`[APPROVAL-PROTO-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`); - return `RPC-PROTO-${i}:HandleCascadeUserInteraction(${typeLower})`; - } catch (e: any) { - ctx.logToFile(`[APPROVAL-PROTO-${i}] ❌ ${e.message.substring(0, 300)}`); - } - } - } - - // ── Strategies 0A-1 REMOVED (v0.3.11) — all confirmed failing, caused log spam + AG interference ── - // Kept: Strategy 0-PROTO (above) for correct proto-based RPC - // Kept: Strategy 2 (below) for renderer DOM click fallback - - // ── Strategy 2: Renderer DOM Click via HTTP Bridge (primary fallback) ── - try { - const triggerAction = approved ? 'approve' : 'reject'; - ctx.logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`); - ctx.setClickTrigger(triggerAction as 'approve' | 'reject'); - ctx.logToFile(`[APPROVAL-2] ✅ clickTrigger set — renderer will poll and click within 2s`); - } catch (e: any) { - ctx.logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`); - } - - ctx.logToFile(`[APPROVAL] strategies complete — check logs for results`); - return `STRATEGIES_DONE:${action}`; -} +// tryApprovalStrategies → moved to ./approval-handler.ts // ─── Activation ─── @@ -1591,6 +1069,7 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string */ export function initStepProbe(context: BridgeContext) { ctx = context; + initApprovalHandler(context, () => activeTrajectoryId); setupMonitor(); setupResponseWatcher(); }