feat(bridge): 승인 감지 최적화 — latestToolCallStep 즉시 감지 + DOM scan 확장
- latestToolCallStep RPC 기반 즉시 감지 (30초 stall → 5초 poll) - DOM scan 범위: findPanel() → document.body 확장 - Accept all/Reject all 리뷰 바 패턴 추가 - Stall detection을 100초 fallback으로 약화 - extractToolCommand/extractToolDescription 헬퍼 추가 - known-issues 5건 신규 추가 - start/services workflow: Python 전체 경로 + services.md 로딩 #task-258 #task-262
This commit is contained in:
@@ -605,7 +605,8 @@ function generateApprovalObserverScript(_port) {
|
||||
// ── Button patterns to detect (order matters: first match wins per scan) ──
|
||||
var PATS=[
|
||||
{re:/^Run$/i, type:'terminal_command'},
|
||||
{re:/^Accept/i, type:'agent_step'},
|
||||
{re:/^Accept all$/i, type:'diff_review'},
|
||||
{re:/^Accept$/i, type:'agent_step'},
|
||||
{re:/^Allow/i, type:'permission'},
|
||||
{re:/^Approve/i, type:'agent_step'},
|
||||
{re:/^Continue$/i, type:'continue'},
|
||||
@@ -613,7 +614,7 @@ function generateApprovalObserverScript(_port) {
|
||||
];
|
||||
|
||||
// Reject button patterns for finding the counterpart
|
||||
var REJECT_RE=[/^reject$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i];
|
||||
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i];
|
||||
|
||||
// ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
|
||||
function btnId(b,type){
|
||||
@@ -679,11 +680,18 @@ function generateApprovalObserverScript(_port) {
|
||||
var now=Date.now();
|
||||
|
||||
var panel=findPanel();
|
||||
if(!panel)return;
|
||||
// Expand search: panel-scoped first, then full body for review bars
|
||||
var searchRoots=[];
|
||||
if(panel)searchRoots.push(panel);
|
||||
// Always also scan body for diff review bar (Accept all/Reject all)
|
||||
// which lives outside the agent panel in the editor notification area
|
||||
if(document.body)searchRoots.push(document.body);
|
||||
if(!searchRoots.length)return;
|
||||
|
||||
// Find ALL buttons in the panel
|
||||
var allBtns=panel.querySelectorAll('button');
|
||||
if(!allBtns.length)return;
|
||||
var seen={}; // dedupe buttons across search roots
|
||||
for(var r=0;r<searchRoots.length;r++){
|
||||
var allBtns=searchRoots[r].querySelectorAll('button');
|
||||
if(!allBtns.length)continue;
|
||||
|
||||
for(var j=0;j<allBtns.length;j++){
|
||||
var b=allBtns[j];
|
||||
@@ -736,6 +744,7 @@ function generateApprovalObserverScript(_port) {
|
||||
// Process ONE button per scan cycle (avoid flooding)
|
||||
return;
|
||||
}
|
||||
} // end searchRoots loop
|
||||
}
|
||||
|
||||
// ── Poll for Discord response ──
|
||||
@@ -903,6 +912,8 @@ function setupMonitor() {
|
||||
let lastKnownStepCount = 0;
|
||||
let lastNotifyStepIndex = -1;
|
||||
let lastTaskStepIndex = -1;
|
||||
let lastToolStepIndex = -1; // track latestToolCallStep for instant detection
|
||||
let toolStepDumped = false; // dump structure once for debugging
|
||||
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step
|
||||
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
|
||||
let lastPendingTime = 0; // cooldown: minimum gap between pendings
|
||||
@@ -975,16 +986,56 @@ function setupMonitor() {
|
||||
if (pollCount <= 10 || pollCount % 6 === 0 || delta > 0) {
|
||||
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
|
||||
}
|
||||
// ── Stall-based approval detection ──
|
||||
// INSIGHT: Both thinking and approval show RUNNING+delta=0.
|
||||
// DIFFERENTIATOR: lastModifiedTime
|
||||
// - Thinking: lastModifiedTime KEEPS CHANGING (server actively processing)
|
||||
// - Approval wait: lastModifiedTime FROZEN (server idle, waiting for user)
|
||||
// DEBUG: dump session keys on first poll to find modTime field
|
||||
// ── PRIMARY: Instant tool-call-based approval detection ──
|
||||
// latestToolCallStep contains the most recent tool call with its status.
|
||||
// If status includes WAITING, the AI is waiting for user approval.
|
||||
const toolStep = bestSession.latestToolCallStep;
|
||||
if (toolStep && toolStep.stepIndex > lastToolStepIndex) {
|
||||
const stepData = toolStep.step || {};
|
||||
// Dump structure once for debugging (keys + first 800 chars)
|
||||
if (!toolStepDumped) {
|
||||
logToFile(`[TOOL-STEP] keys: ${JSON.stringify(Object.keys(toolStep))}`);
|
||||
logToFile(`[TOOL-STEP] step keys: ${JSON.stringify(Object.keys(stepData))}`);
|
||||
logToFile(`[TOOL-STEP] full: ${JSON.stringify(toolStep).substring(0, 800)}`);
|
||||
toolStepDumped = true;
|
||||
}
|
||||
// Check for WAITING status in various possible field locations
|
||||
const stepStatus = String(stepData.status || toolStep.status || stepData.stepStatus || '').toLowerCase();
|
||||
const isWaiting = stepStatus.includes('waiting') || stepStatus.includes('pending');
|
||||
if (isWaiting && toolStep.stepIndex !== lastPendingStepIndex) {
|
||||
// INSTANT detection! Extract tool call details
|
||||
const command = extractToolCommand(stepData);
|
||||
const description = extractToolDescription(stepData, currentTitle, toolStep.stepIndex);
|
||||
logToFile(`[TOOL-DETECT] INSTANT! step=${toolStep.stepIndex} status='${stepStatus}' cmd='${command}'`);
|
||||
lastPendingStepIndex = toolStep.stepIndex;
|
||||
lastPendingTime = Date.now();
|
||||
sawRunningAfterPending = false;
|
||||
writePendingApproval({
|
||||
conversation_id: activeSessionId,
|
||||
command,
|
||||
description,
|
||||
step_type: 'tool_call',
|
||||
step_index: toolStep.stepIndex,
|
||||
});
|
||||
}
|
||||
else if (!isWaiting) {
|
||||
// Tool call exists but not waiting — log for diagnostics
|
||||
if (toolStep.stepIndex > lastToolStepIndex) {
|
||||
logToFile(`[TOOL-STEP] step=${toolStep.stepIndex} status='${stepStatus}' (not waiting)`);
|
||||
}
|
||||
}
|
||||
lastToolStepIndex = toolStep.stepIndex;
|
||||
}
|
||||
// ── FALLBACK: Stall-based approval detection ──
|
||||
// Kept as safety net for edge cases where latestToolCallStep doesn't report WAITING.
|
||||
// Threshold raised from 6 (30s) to 20 (100s) since primary detection above handles most cases.
|
||||
// DEBUG: dump session keys on first poll
|
||||
if (pollCount === 1) {
|
||||
const keys = Object.keys(bestSession).filter(k => !['latestNotifyUserStep', 'latestTaskBoundaryStep', 'latestToolCallStep'].includes(k));
|
||||
logToFile(`[DEBUG] session keys: ${keys.join(', ')}`);
|
||||
logToFile(`[DEBUG] lastModifiedTime=${bestSession.lastModifiedTime}, lastModifiedTimestamp=${bestSession.lastModifiedTimestamp}, modifiedTime=${bestSession.modifiedTime}`);
|
||||
logToFile(`[DEBUG] lastModifiedTime=${bestSession.lastModifiedTime}`);
|
||||
if (toolStep)
|
||||
logToFile(`[DEBUG] toolStep present, stepIndex=${toolStep.stepIndex}`);
|
||||
}
|
||||
const currentModTime = bestSession.lastModifiedTime || bestSession.lastModifiedTimestamp || bestSession.modifiedTime || '';
|
||||
const modTimeChanged = currentModTime !== lastModTime;
|
||||
@@ -1013,17 +1064,17 @@ function setupMonitor() {
|
||||
lastModTime = currentModTime;
|
||||
const now = Date.now();
|
||||
const cooldownOk = (now - lastPendingTime) > 60_000;
|
||||
if (consecutiveIdleCount >= 6 && sawRunningAfterPending && cooldownOk) {
|
||||
// 6 polls × 5s = 30 seconds of FROZEN stall = approval waiting
|
||||
if (consecutiveIdleCount >= 20 && sawRunningAfterPending && cooldownOk) {
|
||||
// 20 polls × 5s = 100 seconds — fallback only
|
||||
lastPendingStepIndex = currentCount;
|
||||
lastPendingTime = now;
|
||||
sawRunningAfterPending = false;
|
||||
const command = `Stall at step ${currentCount}`;
|
||||
const description = `승인 대기 감지 (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
|
||||
logToFile(`[STALL] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
|
||||
const command = `Stall at step ${currentCount} (fallback)`;
|
||||
const description = `승인 대기 감지 — fallback (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
|
||||
logToFile(`[STALL-FALLBACK] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
|
||||
writePendingApproval({ conversation_id: activeSessionId, command, description });
|
||||
}
|
||||
else if (consecutiveIdleCount === 6) {
|
||||
else if (consecutiveIdleCount === 20) {
|
||||
const reasons = [];
|
||||
if (!sawRunningAfterPending)
|
||||
reasons.push('needDelta>0');
|
||||
@@ -1269,6 +1320,49 @@ function filterEphemeral(text) {
|
||||
}
|
||||
return text;
|
||||
}
|
||||
/** Extract human-readable command from a tool call step's data. */
|
||||
function extractToolCommand(stepData) {
|
||||
// Try common step data shapes from protobuf
|
||||
if (stepData.runCommand) {
|
||||
return stepData.runCommand.commandLine || stepData.runCommand.command || 'Run Command';
|
||||
}
|
||||
if (stepData.writeToFile) {
|
||||
const target = stepData.writeToFile.targetFile || stepData.writeToFile.filePath || 'file';
|
||||
return `Write: ${target.split(/[\\/]/).pop()}`;
|
||||
}
|
||||
if (stepData.codeAction) {
|
||||
const fp = stepData.codeAction.filePath || '';
|
||||
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
|
||||
}
|
||||
if (stepData.replaceFileContent || stepData.multiReplaceFileContent) {
|
||||
const d = stepData.replaceFileContent || stepData.multiReplaceFileContent;
|
||||
const fp = d.targetFile || d.filePath || '';
|
||||
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
|
||||
}
|
||||
if (stepData.sendCommandInput) {
|
||||
return `Send Input: ${(stepData.sendCommandInput.input || '').substring(0, 50)}`;
|
||||
}
|
||||
// Generic fallback: use first key name
|
||||
const keys = Object.keys(stepData).filter(k => k !== 'status' && k !== 'stepStatus');
|
||||
return keys.length > 0 ? keys[0] : 'Unknown tool call';
|
||||
}
|
||||
/** Extract description from a tool call step for Discord display. */
|
||||
function extractToolDescription(stepData, sessionTitle, stepIndex) {
|
||||
const parts = [`Step #${stepIndex}`, `Session: "${sessionTitle}"`];
|
||||
// Try to get code/command content for context
|
||||
if (stepData.runCommand) {
|
||||
const cmd = stepData.runCommand.commandLine || stepData.runCommand.command || '';
|
||||
if (cmd)
|
||||
parts.push(`Command: ${cmd.substring(0, 200)}`);
|
||||
}
|
||||
if (stepData.writeToFile?.targetFile) {
|
||||
parts.push(`File: ${stepData.writeToFile.targetFile}`);
|
||||
}
|
||||
if (stepData.codeAction?.filePath) {
|
||||
parts.push(`File: ${stepData.codeAction.filePath}`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
|
||||
function writePendingApproval(data) {
|
||||
try {
|
||||
@@ -1286,6 +1380,8 @@ function writePendingApproval(data) {
|
||||
status: 'pending',
|
||||
discord_message_id: 0,
|
||||
project_name: projectName,
|
||||
...(data.step_type ? { step_type: data.step_type } : {}),
|
||||
...(data.step_index !== undefined ? { step_index: data.step_index } : {}),
|
||||
};
|
||||
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
|
||||
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user