fix(observer): v9 - stop treating Running N commands as approval button, add DOM-climbing context extraction

This commit is contained in:
Variet Worker
2026-04-13 19:37:18 +09:00
parent 2a1ebf1020
commit a8d875167d
6 changed files with 119 additions and 18 deletions

View File

@@ -16,6 +16,19 @@
> 뵒踰꾧퉭씠굹 援ы쁽 쟾뿉 **諛섎뱶떆** 씠 뙆뙆씪쓣 솗씤븯꽭슂. > 뵒踰꾧퉭씠굹 援ы쁽 쟾뿉 **諛섎뱶떆** 씠 뙆뙆씪쓣 솗씤븯꽭슂.
> 꽭뀡 醫낅즺 떆 깉濡 諛쒓껄맂 씠뒋瑜 씠 뙆뙆씪뿉 異붽빀땲떎. > 꽭뀡 醫낅즺 떆 깉濡 諛쒓껄맂 씠뒋瑜 씠 뙆뙆씪뿉 異붽빀땲떎.
> [!TIP]
> 빐寃 셿猷뚮맂 怨쇨굅 씠뒋뒗 [`known-issues-archive.md`](file:///c:/Users/Variet-Worker/Desktop/gravity_control/.agents/references/known-issues-archive.md)뿉 蹂닿릺뼱 엳뒿땲떎.
> 鍮꾩듂븳 臾몄젣媛 옱諛쒗븯硫 archive뿉꽌 寃깋븯꽭슂.
---
### [2026-04-13] [Extension] Observer v8 "Running N commands" 그룹 헤더를 승인 버튼으로 오인 — Discord 빈 내용+잘못된 버튼
- **증상**: Discord 승인 요청에 command="Running2 commands", description 비어있음, 버튼도 "Running2 commands / Always run" 형태. 실제 코드/명령어 내용이 전혀 표시되지 않음
- **원인 1**: `observer-script.ts``isActionBtn()``/Running\\s*\\d*\\s*command/i` 패턴이 있어 AG UI의 그룹 헤더 버튼("Running 3 commands")을 승인 버튼으로 분류. `scan()`이 이 버튼을 먼저 만나고 `break`로 나가 실제 "Always run"/"Cancel" 버튼은 처리 안 됨
- **원인 2**: `extractStepContext()``data-step-index` 속성 없으면 `cleanButtonText(btn)` = "Running2 commands"를 그대로 반환. AG Native에는 `data-step-index`/`data-testid` 속성이 없음 (DOM 덤프로 확인)
- **원인 3**: `http-bridge.ts`의 "Run/Always run" 필터가 step-probe 미활성(activeSessionId 비어있음) 시에도 DOM observer 신호를 차단
- **해결**: observer v9 (v0.5.40):
1. `isActionBtn()`에서 "Running N commands" 패턴 제거
2. `scan()`에서 `^Running\\s*\\d+\\s*commands?$` 명시적 스킵 2. `scan()`에서 `^Running\\s*\\d+\\s*commands?$` 명시적 스킵
3. `extractContextFromNearby()` 추가: `data-step-index` 없이 DOM 트리를 20레벨까지 올라가며 `pre`/`code` 블록에서 실제 명령어 추출 3. `extractContextFromNearby()` 추가: `data-step-index` 없이 DOM 트리를 20레벨까지 올라가며 `pre`/`code` 블록에서 실제 명령어 추출
4. `collectSiblingButtons()` 범위를 parent → grandparent → great-grandparent로 확대, 그룹 헤더 스킵 4. `collectSiblingButtons()` 범위를 parent → grandparent → great-grandparent로 확대, 그룹 헤더 스킵

View File

@@ -4,3 +4,4 @@
|-------|-------|----------|-----------|----------| |-------|-------|----------|-----------|----------|
| 001 | 09:50 | Observer v8 검증 — Extension POLL 확인, HTML 패치 확인, V8 캐시 삭제(24MB), BEACON 미수신(AG 재시작 필요) | 없음 | 🔧 | | 001 | 09:50 | Observer v8 검증 — Extension POLL 확인, HTML 패치 확인, V8 캐시 삭제(24MB), BEACON 미수신(AG 재시작 필요) | 없음 | 🔧 |
| 002 | 12:34 | DOM Observer 데이터 품질 검증 + UTF-8 인코딩 수정 + noise 필터 강화 (v0.5.39) | `pending` | ✅ | | 002 | 12:34 | DOM Observer 데이터 품질 검증 + UTF-8 인코딩 수정 + noise 필터 강화 (v0.5.39) | `pending` | ✅ |
| 003 | 19:26 | Observer v9: "Running N commands" 오인 수정 + DOM-climbing 컨텍스트 추출 + http-bridge 필터 완화 (v0.5.40) | `pending` | 🔧 |

View File

@@ -0,0 +1,28 @@
# DOM Observer 컨텍스트 추출 수정 — v9 (v0.5.40)
- **시간**: 2026-04-13 19:26~
- **Commit**: `pending`
- **Vikunja**: #619, #620 (진행 중)
## 문제
Discord 승인 요청에 내용이 비어있음:
- command = "Running2 commands" (그룹 헤더 버튼을 잘못 캡처)
- description = 비어있거나 UI 노이즈만 포함
- buttons = "Running2 commands / Always run" (잘못된 구조)
## 변경 사항
### observer-script.ts (v8 → v9)
1. `isActionBtn()`에서 "Running N commands" 패턴 제거 — 이것은 그룹 헤더이며 승인 버튼이 아님
2. `scan()`에서 `^Running\s*\d+\s*commands?$` 명시적 스킵
3. `extractContextFromNearby()` 신규 함수 추가 — `data-step-index` 없이 DOM 트리를 20레벨까지 올라가며 `pre`/`code` 블록에서 실제 명령어 추출
4. `collectSiblingButtons()` 범위를 parent → grandparent → great-grandparent 3레벨로 확대, 그룹 헤더 스킵, 텍스트 기반 dedup 추가
5. `matchedType` 판별에서 `/Running\d/` 패턴 제거
### http-bridge.ts
6. "Run/Always run" 필터에 `ctx.activeSessionId` 체크 추가 — step-probe가 세션 미추적 시 DOM observer 신호 허용
## 미완료
- AG 재시작 후 v0.5.40 적용 검증
- Discord E2E 검증 (실제 명령어/코드 내용 표시 확인)

View File

@@ -2,7 +2,7 @@
"name": "gravity-bridge", "name": "gravity-bridge",
"displayName": "Gravity Bridge", "displayName": "Gravity Bridge",
"description": "Discord-based unified approval system for Antigravity AI interactions.", "description": "Discord-based unified approval system for Antigravity AI interactions.",
"version": "0.5.39", "version": "0.5.40",
"publisher": "variet", "publisher": "variet",
"engines": { "engines": {
"vscode": "^1.100.0" "vscode": "^1.100.0"

View File

@@ -267,14 +267,16 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
return; return;
} }
// "Run" button → step_probe handles these with full command detail // "Run" button → step_probe handles these with full command detail
// Only let through if session is stalled AND step_probe hasn't created a pending yet // Only filter when step_probe IS actively tracking a session
if (/^(?:Always\s*)?Run\b/i.test(cmd)) { if (/^(?:Always\s*)?Run\b/i.test(cmd)) {
if (!ctx.sessionStalled || ctx.lastPendingStepIndex >= 0) { if (ctx.activeSessionId && (!ctx.sessionStalled || ctx.lastPendingStepIndex >= 0)) {
ctx.logToFile(`[HTTP] filtered "Run" — ${!ctx.sessionStalled ? 'not stalled' : 'step_probe pending exists'}`); ctx.logToFile(`[HTTP] filtered "Run" — ${!ctx.sessionStalled ? 'not stalled' : 'step_probe pending exists'} (session=${ctx.activeSessionId.substring(0, 8)})`);
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true })); res.end(JSON.stringify({ ok: false, filtered: true }));
return; return;
} }
// v9: When step_probe has no active session, let DOM observer handle approval
ctx.logToFile(`[HTTP] allowing "Run" — step_probe has no active session`);
} }
const rid = data.request_id || Date.now().toString(); const rid = data.request_id || Date.now().toString();

View File

@@ -1,7 +1,7 @@
export function generateApprovalObserverScript(_port: number): string { export function generateApprovalObserverScript(_port: number): string {
return ` return `
// ── Gravity Bridge v8: Full-DOM AG Native Parser ── // ── Gravity Bridge v9: Context-Aware AG Native Parser ──
// Full body dump + step-aware parsing — no hardcoded selector dependency // v9: Fixed 'Running N commands' false trigger + DOM-climbing context extraction
(function(){ (function(){
'use strict'; 'use strict';
var BASE='',_obs=false,_sent={},_ready=false; var BASE='',_obs=false,_sent={},_ready=false;
@@ -10,7 +10,7 @@ export function generateApprovalObserverScript(_port: number): string {
var CLEANUP_MS=300000; var CLEANUP_MS=300000;
function log(m){console.log('[GB Observer] '+m);} function log(m){console.log('[GB Observer] '+m);}
log('v8 Script loaded — Full-DOM AG Native Parser'); log('v9 Script loaded — Context-Aware AG Native Parser');
// DIAGNOSTIC BEACON: immediate POST to confirm script execution in renderer // DIAGNOSTIC BEACON: immediate POST to confirm script execution in renderer
try { try {
@@ -109,9 +109,47 @@ export function generateApprovalObserverScript(_port: number): string {
return null; return null;
} }
// v9: Climb DOM tree to find pre/code content near the button (no data-step-index needed)
function extractContextFromNearby(btn) {
var node = btn;
for (var depth = 0; depth < 20 && node; depth++) {
if (!node.querySelector) { node = node.parentElement; continue; }
// Look for code/pre blocks (actual command text)
var codeEls = node.querySelectorAll('pre, code, [class*="terminal"]');
for (var ci = 0; ci < codeEls.length; ci++) {
var codeText = cleanLines((codeEls[ci].textContent || '').trim().substring(0, 500));
if (codeText && codeText.length > 5 && !/^Running\\s*\\d/i.test(codeText)) {
// Also try to get a header/title near this container
var headerEl = node.querySelector('h1, h2, h3, [class*="header"], [class*="title"], [class*="cursor-pointer"]');
var headerText = '';
if (headerEl) {
var hClone = headerEl.cloneNode(true);
var hRem = hClone.querySelectorAll('button, svg, [class*="icon"], .google-symbols');
for (var hi = 0; hi < hRem.length; hi++) {
if (hRem[hi].parentNode) hRem[hi].parentNode.removeChild(hRem[hi]);
}
headerText = cleanLines((hClone.textContent || '').trim().substring(0, 200));
}
var parts = [];
if (headerText) parts.push(headerText);
parts.push(codeText);
return parts.join(' — ');
}
}
node = node.parentElement;
}
// Last resort: try aria-label or title on the button
var ariaLabel = btn.getAttribute('aria-label') || btn.getAttribute('title') || '';
if (ariaLabel && ariaLabel.length > 5) return ariaLabel;
return cleanButtonText(btn);
}
function extractStepContext(btn) { function extractStepContext(btn) {
var stepEl = getStepContainer(btn); var stepEl = getStepContainer(btn);
if (!stepEl) return cleanButtonText(btn); if (!stepEl) {
// v9 FALLBACK: no data-step-index — climb DOM for pre/code blocks
return extractContextFromNearby(btn);
}
var stepIdx = stepEl.getAttribute('data-step-index') || '?'; var stepIdx = stepEl.getAttribute('data-step-index') || '?';
@@ -161,7 +199,7 @@ export function generateApprovalObserverScript(_port: number): string {
for(var i=0; i<ACTION_WORDS.length; i++) { for(var i=0; i<ACTION_WORDS.length; i++) {
if(txt.indexOf(ACTION_WORDS[i]) !== -1) return true; if(txt.indexOf(ACTION_WORDS[i]) !== -1) return true;
} }
if (/Running\\s*\\d*\\s*command/i.test(txt)) return true; // v9: Removed "Running N commands" — it's a group header, not an approval button
return false; return false;
} }
function isRejectBtn(txt) { function isRejectBtn(txt) {
@@ -173,16 +211,32 @@ export function generateApprovalObserverScript(_port: number): string {
function collectSiblingButtons(container,triggerBtn){ function collectSiblingButtons(container,triggerBtn){
if(!container)return []; if(!container)return [];
var siblings=container.querySelectorAll('button'); // v9: Try multiple container levels (parent → grandparent → great-grandparent)
// to find all related approval buttons in wider DOM context
var containers = [container];
if (container.parentElement) containers.push(container.parentElement);
if (container.parentElement && container.parentElement.parentElement)
containers.push(container.parentElement.parentElement);
var result=[]; var result=[];
var seen={};
for(var ci=0;ci<containers.length;ci++){
var siblings=containers[ci].querySelectorAll('button');
for(var i=0;i<siblings.length;i++){ for(var i=0;i<siblings.length;i++){
var sb=siblings[i]; var sb=siblings[i];
if(sb.disabled||sb.hidden||(!sb.offsetParent&&sb.style.display!=='fixed'))continue; if(sb.disabled||sb.hidden||(!sb.offsetParent&&sb.style.display!=='fixed'))continue;
var stxt = cleanButtonText(sb); var stxt = cleanButtonText(sb);
if(stxt.length <= 1) continue; if(stxt.length <= 1) continue;
// Skip group headers
if (/^Running\\s*\\d+\\s*commands?$/i.test(stxt)) continue;
if(!isActionBtn(stxt) && !isRejectBtn(stxt)) continue; if(!isActionBtn(stxt) && !isRejectBtn(stxt)) continue;
// Dedup by text
if(seen[stxt])continue;
seen[stxt]=true;
result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)}); result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
} }
// If we found action buttons at this level, don't go wider
if(result.length > 0) break;
}
return result; return result;
} }
@@ -524,13 +578,16 @@ export function generateApprovalObserverScript(_port: number): string {
var txt=cleanButtonText(b); var txt=cleanButtonText(b);
if(txt.length <= 1) continue; if(txt.length <= 1) continue;
// v9: Skip group header buttons — not approval buttons
if (/^Running\\s*\\d+\\s*commands?$/i.test(txt)) continue;
if(!isActionBtn(txt)) continue; if(!isActionBtn(txt)) continue;
// Skip inline code lens buttons // Skip inline code lens buttons
if (b.closest('.codelens-decoration') && !txt.includes('Accept')) { if (b.closest('.codelens-decoration') && !txt.includes('Accept')) {
continue; continue;
} }
var matchedType = txt.includes('Accept') ? 'diff_review' : (txt.includes('Run') || /Running\\d/.test(txt) ? 'command' : 'permission'); var matchedType = txt.includes('Accept') ? 'diff_review' : (txt.includes('Run') || txt.includes('Allow') ? 'command' : 'permission');
// v7: Use step-index for more unique group key // v7: Use step-index for more unique group key
var stepContainer = getStepContainer(b); var stepContainer = getStepContainer(b);