/** * 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 (VS Code commands, RPC, DOM click) * - Diff review Accept/Reject via VS Code commands * * STRATEGY ORDER (most reliable first): * 0. antigravity.acceptAgentStep / rejectAgentStep — AG's own commands, always works * 1. HandleCascadeUserInteraction RPC — cross-platform, needs stepIndex * 2. DOM click trigger via HTTP bridge — fallback */ 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); // v22: Skip files written by the WS response handler (extension.ts onResponse). // Those files exist ONLY for Observer's pollResponseGroup to read via HTTP. // The WS handler already calls tryApprovalStrategies, so processing here is redundant. // Without this skip, the watcher deletes the file before Observer can poll it // (since no pending file exists for the isDomObserver check). if (resp._from_ws) { ctx.logToFile(`[RESPONSE] SKIP _from_ws file (for Observer pollResponseGroup): ${resp.request_id}`); return; } 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 (v3.0) ═══ 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 { // ALL paths (dom_observer + step_probe) use same strategy pipeline const targetSession = sessionId || ctx.activeSessionId; ctx.logToFile(`[RESPONSE] → 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): * 0. antigravity.acceptAgentStep / rejectAgentStep (AG VS Code commands — always works) * 1. HandleCascadeUserInteraction RPC (cross-platform, needs stepIndex) * 2. Renderer DOM Click via HTTP Bridge (fallback) */ 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 >= 0 ? ctx.lastPendingStepIndex : -1); ctx.logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${effectiveStepIndex}`); // ══════════════════════════════════════════════════════════ // STRATEGY 0: SDK-verified AG commands (step_type-aware dispatch) // // From SDK index.js (verified command mapping): // antigravity.agent.acceptAgentStep — code edits, file writes // antigravity.agent.rejectAgentStep — reject code edits // antigravity.command.accept — non-terminal commands (Run, Allow, etc.) // antigravity.command.reject — reject non-terminal commands // antigravity.terminalCommand.accept — terminal commands // antigravity.terminalCommand.reject — reject terminal commands // antigravity.terminalCommand.run — run terminal commands // // These operate on the currently focused/active step — no stepIndex needed! // ══════════════════════════════════════════════════════════ { const typeLower = stepType.toLowerCase().replace('cortex_step_type_', ''); // Determine which SDK command pair to use based on step_type let acceptCmd: string; let rejectCmd: string; if (typeLower.includes('code_edit') || typeLower.includes('write_to_file') || typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit') || typeLower === 'diff_review') { // Code edits → agent step commands acceptCmd = 'antigravity.agent.acceptAgentStep'; rejectCmd = 'antigravity.agent.rejectAgentStep'; } else if (typeLower.includes('run_command') || typeLower.includes('shell_exec') || typeLower.includes('send_command_input')) { // Terminal commands → terminal command pair acceptCmd = 'antigravity.terminalCommand.accept'; rejectCmd = 'antigravity.terminalCommand.reject'; } else if (typeLower === 'command' || typeLower.includes('permission') || typeLower.includes('browser') || typeLower.includes('mcp') || typeLower.includes('extension_code') || typeLower.includes('subagent') || typeLower.includes('open_browser') || typeLower.includes('read_url') || typeLower.includes('invoke_subagent')) { // Non-terminal commands (Run, Allow, etc.) → command pair acceptCmd = 'antigravity.command.accept'; rejectCmd = 'antigravity.command.reject'; } else { // Unknown type — try all three in order acceptCmd = 'antigravity.command.accept'; rejectCmd = 'antigravity.command.reject'; } const primaryCmd = approved ? acceptCmd : rejectCmd; ctx.logToFile(`[APPROVAL-0] stepType="${stepType}" → ${primaryCmd}`); try { await vscode.commands.executeCommand(primaryCmd); ctx.logToFile(`[APPROVAL-0] ✅ ${primaryCmd} SUCCESS`); return `SDK:${primaryCmd}`; } catch (e: any) { ctx.logToFile(`[APPROVAL-0] ❌ ${primaryCmd} failed: ${e.message?.substring(0, 200)}`); } // Fallback: if the primary type-specific command failed, try the other pairs const fallbackPairs = [ approved ? 'antigravity.command.accept' : 'antigravity.command.reject', approved ? 'antigravity.agent.acceptAgentStep' : 'antigravity.agent.rejectAgentStep', approved ? 'antigravity.terminalCommand.accept' : 'antigravity.terminalCommand.reject', ].filter(cmd => cmd !== primaryCmd); // skip already-tried for (const fallbackCmd of fallbackPairs) { try { ctx.logToFile(`[APPROVAL-0-FB] Trying ${fallbackCmd}...`); await vscode.commands.executeCommand(fallbackCmd); ctx.logToFile(`[APPROVAL-0-FB] ✅ ${fallbackCmd} SUCCESS`); return `SDK-FB:${fallbackCmd}`; } catch (e: any) { ctx.logToFile(`[APPROVAL-0-FB] ❌ ${fallbackCmd}: ${e.message?.substring(0, 100)}`); } } } // ══════════════════════════════════════════════════════════ // STRATEGY 1: HandleCascadeUserInteraction RPC // Now supports BOTH approve AND reject. // Requires valid stepIndex for most step types. // ══════════════════════════════════════════════════════════ if (ctx.sdk && effectiveStepIndex >= 0) { const typeLower = stepType.toLowerCase().replace('cortex_step_type_', ''); let interactionPayload: Record = {}; // Code edit steps — use dedicated RPC if (typeLower.includes('code_edit') || typeLower.includes('write_to_file') || typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit')) { try { ctx.logToFile(`[APPROVAL-1-CODE] trying submitCodeAcknowledgement command`); await vscode.commands.executeCommand('antigravity.prioritized.submitCodeAcknowledgement'); ctx.logToFile(`[APPROVAL-1-CODE] ✅ submitCodeAcknowledgement OK`); return `CMD:submitCodeAcknowledgement(accept=${approved})`; } catch { ctx.logToFile(`[APPROVAL-1-CODE] submitCodeAcknowledgement not available, trying RPC`); } try { ctx.logToFile(`[APPROVAL-1-CODE] 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-1-CODE] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`); return `RPC:acknowledgeCodeActionStep(accept=${approved})`; } catch (e: any) { ctx.logToFile(`[APPROVAL-1-CODE] ❌ ${e.message.substring(0, 200)}`); // Fall through to generic HandleCascadeUserInteraction interactionPayload = { runCommand: { confirm: approved } }; } } // Map step_type to interaction sub-message field // CRITICAL FIX: Use `confirm: approved` (not always true) to support REJECT if (typeLower.includes('run_command') || typeLower.includes('shell_exec')) { interactionPayload = { runCommand: { confirm: approved } }; } else if (typeLower.includes('open_browser')) { interactionPayload = { openBrowserUrl: { confirm: approved } }; } else if (typeLower.includes('send_command_input')) { interactionPayload = { sendCommandInput: { confirm: approved } }; } else if (typeLower.includes('read_url')) { interactionPayload = { readUrlContent: { confirm: approved } }; } else if (typeLower.includes('mcp')) { interactionPayload = { mcpTool: { confirm: approved } }; } else if (typeLower.includes('invoke_subagent') || typeLower.includes('extension_code') || typeLower.includes('browser_subagent')) { interactionPayload = { runExtensionCode: { confirm: approved } }; } else if (typeLower.includes('file_permission')) { if (typeLower.includes('deny')) { interactionPayload = { filePermission: { allow: false, scope: 1 } }; } else { const scope = typeLower.includes('conversation') ? 2 : 1; interactionPayload = { filePermission: { allow: approved, scope } }; } } else if (typeLower.includes('elicitation')) { interactionPayload = { elicitation: {} }; } else if (typeLower === 'permission' || typeLower.includes('permission')) { interactionPayload = { runExtensionCode: { confirm: approved } }; } else if (typeLower === 'command' || typeLower === '') { // Generic command — most common case from DOM observer interactionPayload = { runCommand: { confirm: approved } }; } else { // Default: try run_command interactionPayload = { runCommand: { confirm: approved } }; } const activeTrajectoryId = getTrajectoryId(); const protoVariants = [ // Variant A: camelCase with trajectoryId { 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, }, }, ]; let lastRpcError = ''; for (let i = 0; i < protoVariants.length; i++) { try { const payload = protoVariants[i]; ctx.logToFile(`[APPROVAL-1-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`); const rpcResult = await ctx.sdk.ls.rawRPC('HandleCascadeUserInteraction', payload); ctx.logToFile(`[APPROVAL-1-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`); return `RPC-${i}:HandleCascadeUserInteraction(${typeLower},${action})`; } catch (e: any) { lastRpcError = e.message || ''; ctx.logToFile(`[APPROVAL-1-${i}] ❌ ${lastRpcError.substring(0, 300)}`); } } // ── Auto-recovery: wrong-LS detection ────────────────────── if (ctx.fixLSConnection && lastRpcError.includes('input not registered')) { ctx.logToFile('[APPROVAL] ⚠️ wrong-LS detected ("input not registered"), attempting LS fix...'); try { const lsChanged = await ctx.fixLSConnection(); if (lsChanged) { ctx.logToFile('[APPROVAL] LS reconnected — retrying first proto variant...'); try { const retryPayload = protoVariants[0]; const retryResult = await ctx.sdk.ls.rawRPC('HandleCascadeUserInteraction', retryPayload); ctx.logToFile(`[APPROVAL-RETRY] ✅ SUCCESS: ${JSON.stringify(retryResult).substring(0, 200)}`); return `RPC-RETRY:HandleCascadeUserInteraction(${typeLower},${action})`; } catch (retryErr: any) { ctx.logToFile(`[APPROVAL-RETRY] ❌ ${retryErr.message?.substring(0, 200)}`); } } else { ctx.logToFile('[APPROVAL] LS not changed — already on correct port or fix unavailable'); } } catch (fixErr: any) { ctx.logToFile(`[APPROVAL] fixLSConnection error: ${fixErr.message?.substring(0, 200)}`); } } } else if (ctx.sdk && effectiveStepIndex < 0) { ctx.logToFile(`[APPROVAL-1] SKIPPED RPC: stepIndex=${effectiveStepIndex} (unknown) — Strategy 0 (VS Code command) was the primary attempt`); } // ══════════════════════════════════════════════════════════ // STRATEGY 2: Renderer DOM Click via HTTP Bridge (fallback) // Sets a click trigger that the observer script polls and executes. // ══════════════════════════════════════════════════════════ 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] All strategies complete for ${action}`); return `STRATEGIES_DONE:${action}`; }