Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions structure/str_func.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ aliases: [agbrowse source map, agbrowse str_func, agbrowse 파일 구조]

## 현재 구조 스냅샷

마지막 측정: 2026-07-11.
마지막 측정: 2026-07-27.

| 경로 | 파일 수 | 라인 수 | 역할 |
| --- | ---: | ---: | --- |
Expand All @@ -25,13 +25,13 @@ aliases: [agbrowse source map, agbrowse str_func, agbrowse 파일 구조]
| `skills/search/` | 5 | 896 | standalone search skill doc (any CLI agent) |
| `skills/vision-click/` | 4 | 1215 | screenshot to coordinate click helper |
| `skills/web-ai/` | 3 | 872 | bundled agent workflow skill |
| `web-ai/` | 113 | 27479 | provider automation, sessions, MCP, eval, policy, trace |
| `web-ai/` | 113 | 27694 | provider automation, sessions, MCP, eval, policy, trace |
| `web-ai/context-pack/` | 8 | 858 | file selection, token budget, context rendering |
| `web-ai/eval/` | 5 | 553 | offline provider DOM fixture harness |
| `web-ai/policy/` | 4 | 238 | mutation and content-boundary guardrails |
| `web-ai/trace/` | 5 | 444 | trace ID, redaction, report, writer helpers |
| `scripts/` | 10 | 1621 | eval runner, release scripts, named release gates, strict-baseline / module-graph / bin smoke checks |
| `test/unit/` | 141 | 17677 | deterministic module tests |
| `test/unit/` | 142 | 17835 | deterministic module tests |
| `test/integration/` | 21 | 3167 | CLI, MCP, policy, provider fixture tests |
| `test/e2e/` | 1 | 50 | browser smoke coverage |
| `test/spec/` | 2 | 35 | high-level contract specs |
Expand Down Expand Up @@ -82,9 +82,9 @@ aliases: [agbrowse source map, agbrowse str_func, agbrowse 파일 구조]
| `skills/browser/adaptive-fetch/browser-runtime.mjs` | 38 | browser page acquisition and cleanup |
| `web-ai/cli.mjs` | 2010 | `web-ai` subcommand parser and command orchestration |
| `web-ai/session-target-guard.mjs` | 151 | shared CDP session candidate selection, ambiguity errors, and target-mismatch recovery envelopes |
| `web-ai/chatgpt.mjs` | 1110 | ChatGPT provider send/poll/query/status with streaming-safe recovery gates |
| `web-ai/chatgpt-response-dom.mjs` | 74 | shared ChatGPT top-level assistant DOM extraction helpers |
| `web-ai/chatgpt-response-observer.mjs` | 190 | ChatGPT observer wake signal and timeout recovery metadata |
| `web-ai/chatgpt.mjs` | 1222 | ChatGPT provider send/poll/query/status with streaming-safe recovery gates |
| `web-ai/chatgpt-response-dom.mjs` | 170 | shared ChatGPT top-level assistant DOM extraction helpers |
| `web-ai/chatgpt-response-observer.mjs` | 197 | ChatGPT observer wake signal and timeout recovery metadata |
| `web-ai/gemini-live.mjs` | 804 | Gemini provider send/poll/query/status |
| `web-ai/grok-live.mjs` | 594 | Grok provider send/poll/query/status |
| `web-ai/mcp-server.mjs` | 467 | stdio JSON-RPC MCP bridge |
Expand Down
153 changes: 153 additions & 0 deletions test/unit/web-ai-assistant-read-deadline.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest';

// Regression coverage for #88: the poll loop only re-checked its deadline at the
// `while` boundary, and `readAssistantMessages()` awaited `page.evaluate()` with
// no per-call bound. A never-resolving evaluate therefore suspended the command
// past --timeout instead of returning a recoverable poll timeout.

describe('bounded assistant DOM reads (#88)', () => {
it('withAssistantReadTimeout resolves the sentinel instead of hanging', async () => {
const { withAssistantReadTimeout, ASSISTANT_READ_TIMED_OUT } =
await import('../../web-ai/chatgpt-response-dom.mjs');

const neverResolves = new Promise(() => {});
const started = Date.now();
const result = await withAssistantReadTimeout(neverResolves, 50);

expect(result).toBe(ASSISTANT_READ_TIMED_OUT);
expect(Date.now() - started).toBeLessThan(2_000);
});

it('withAssistantReadTimeout returns the value when the read wins', async () => {
const { withAssistantReadTimeout } = await import('../../web-ai/chatgpt-response-dom.mjs');
await expect(withAssistantReadTimeout(Promise.resolve(['answer']), 5_000))
.resolves.toEqual(['answer']);
});

it('withAssistantReadTimeout treats a rejected read as timed out, never throws', async () => {
const { withAssistantReadTimeout, ASSISTANT_READ_TIMED_OUT } =
await import('../../web-ai/chatgpt-response-dom.mjs');
await expect(withAssistantReadTimeout(Promise.reject(new Error('detached')), 5_000))
.resolves.toBe(ASSISTANT_READ_TIMED_OUT);
});

it('resolveAssistantReadBudgetMs clamps to the remaining deadline', async () => {
const { resolveAssistantReadBudgetMs, ASSISTANT_READ_TIMEOUT_MS } =
await import('../../web-ai/chatgpt-response-dom.mjs');

expect(resolveAssistantReadBudgetMs(undefined)).toBe(ASSISTANT_READ_TIMEOUT_MS);
expect(resolveAssistantReadBudgetMs(500)).toBe(500);
expect(resolveAssistantReadBudgetMs(ASSISTANT_READ_TIMEOUT_MS + 60_000))
.toBe(ASSISTANT_READ_TIMEOUT_MS);
expect(resolveAssistantReadBudgetMs(0)).toBe(0);
expect(resolveAssistantReadBudgetMs(-5)).toBe(0);
});

it('pollWebAi returns a recoverable timeout when every assistant read stalls', async () => {
const { pollWebAi } = await import('../../web-ai/chatgpt.mjs');
const { createSession } = await import('../../web-ai/session.mjs');

const session = createSession(
{ vendor: 'chatgpt', prompt: 'huge conversation', attachmentPolicy: 'inline-only' },
{
targetId: 'target-stalled-dom',
conversationUrl: 'https://chatgpt.com/c/stalled',
deadlineAt: new Date(Date.now() + 60_000).toISOString(),
envelopeSummary: { assistantCount: 3 },
},
);

let evaluateCalls = 0;
const page = {
url: () => 'https://chatgpt.com/c/stalled',
// Simulates the reported failure: assistant-message extraction never
// returns because the conversation is too large to serialize.
evaluate: async () => {
evaluateCalls += 1;
return new Promise(() => {});
},
waitForTimeout: async (ms) => new Promise(resolve => setTimeout(resolve, Math.min(Number(ms) || 0, 20))),
locator: () => ({
first: () => ({ isVisible: async () => false }),
all: async () => [],
}),
innerText: async () => '',
};

const started = Date.now();
const result = await pollWebAi({
getPage: async () => page,
getTargetId: async () => 'target-stalled-dom',
}, {
vendor: 'chatgpt',
session: session.sessionId,
timeout: 2,
});
const elapsedMs = Date.now() - started;

expect(result).toMatchObject({
ok: false,
vendor: 'chatgpt',
status: 'timeout',
sessionId: session.sessionId,
recoverable: true,
retryHint: 'poll-or-resume',
});
// The command must honor its own deadline rather than parking forever.
expect(elapsedMs).toBeLessThan(30_000);
expect(evaluateCalls).toBeGreaterThan(0);
// A stalled read is reported distinctly from "provider still generating".
expect(result.warnings.some(w => String(w).startsWith('assistant-dom-read-timeout:'))).toBe(true);
}, 40_000);
});

describe('post-baseline assistant extraction (#88)', () => {
it('serializes only turns at/after the baseline index', async () => {
const { readAssistantTextsAfterIndex } = await import('../../web-ai/chatgpt-response-dom.mjs');

const serialized = [];
const makeNode = (label) => ({
get innerText() {
serialized.push(label);
return label;
},
textContent: label,
contains: () => false,
});
const nodes = ['old-1', 'old-2', 'old-3', 'new-1'].map(makeNode);
const selectors = ['[data-message-author-role="assistant"]'];

const previous = globalThis.document;
globalThis.document = {
querySelectorAll: (selector) => (selector === selectors[0] ? nodes : []),
};
try {
const result = readAssistantTextsAfterIndex({ selectors, minIndex: 3 });
expect(result.total).toBe(4);
expect(result.texts).toEqual(['new-1']);
// Historical turns must not be re-serialized on every tick.
expect(serialized).toEqual(['new-1']);
} finally {
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
}
});

it('reports the full turn count so baseline math stays correct', async () => {
const { readAssistantTextsAfterIndex } = await import('../../web-ai/chatgpt-response-dom.mjs');
const selectors = ['[data-message-author-role="assistant"]'];
const nodes = ['a', 'b'].map(text => ({ innerText: text, textContent: text, contains: () => false }));

const previous = globalThis.document;
globalThis.document = {
querySelectorAll: (selector) => (selector === selectors[0] ? nodes : []),
};
try {
expect(readAssistantTextsAfterIndex({ selectors, minIndex: 0 }))
.toEqual({ total: 2, texts: ['a', 'b'] });
} finally {
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
}
});
});
16 changes: 10 additions & 6 deletions test/unit/web-ai-provider-session.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -404,19 +404,23 @@ function createCopyMarkdownDeferredChatGptPage(text) {
textContent: 'Answer now',
contains: () => false,
};
let evaluateCount = 0;
let readCount = 0;
let stopVisible = false;
return {
url: () => 'https://chatgpt.com/c/copy-streaming',
evaluate: async (fn, selectors) => {
evaluateCount += 1;
const currentNode = evaluateCount === 1 ? node : placeholderNode;
evaluate: async (fn, arg) => {
// Assistant reads pass either the selector array or { selectors,
// minIndex }; count only those so unrelated evaluate calls (stop
// button, finished-actions probes) do not advance the sequence.
const selectors = Array.isArray(arg) ? arg : arg?.selectors;
if (selectors) readCount += 1;
const currentNode = readCount <= 1 ? node : placeholderNode;
const previous = globalThis.document;
globalThis.document = {
querySelectorAll: (selector) => selector === selectors[0] ? [currentNode] : [],
querySelectorAll: (selector) => selectors && selector === selectors[0] ? [currentNode] : [],
};
try {
return fn(selectors);
return fn(arg);
} finally {
if (previous === undefined) delete globalThis.document;
else globalThis.document = previous;
Expand Down
96 changes: 96 additions & 0 deletions web-ai/chatgpt-response-dom.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
// @ts-check

/**
* Per-read ceiling for a single assistant-DOM read. Playwright's
* `page.evaluate()` takes no timeout option, so a stalled or very slow
* evaluation (large conversation, blocked main thread) can otherwise park the
* poll loop past its own deadline (#88).
*/
export const ASSISTANT_READ_TIMEOUT_MS = 10_000;

export const CHATGPT_ASSISTANT_SELECTORS = [
'[data-message-author-role="assistant"]',
'[data-turn="assistant"]',
Expand All @@ -11,6 +19,59 @@ export const CHATGPT_STOP_SELECTORS = [
'button[aria-label*="Stop" i]',
];

/**
* Sentinel resolved when a bounded read exceeds its budget. Distinct from `[]`
* so callers can tell "no assistant turns yet" from "the read did not finish".
*/
export const ASSISTANT_READ_TIMED_OUT = Symbol('assistant-read-timed-out');

/**
* Bound any assistant-DOM read by a deadline. Resolves the task's value, or
* ASSISTANT_READ_TIMED_OUT when the budget elapses first. The underlying task is
* never awaited further; its rejection is swallowed so an abandoned read cannot
* surface as an unhandled rejection.
* @template T
* @param {Promise<T>} task
* @param {number} timeoutMs
* @returns {Promise<T | typeof ASSISTANT_READ_TIMED_OUT>}
*/
export async function withAssistantReadTimeout(task, timeoutMs) {
const budget = Number(timeoutMs);
if (!Number.isFinite(budget) || budget <= 0) return ASSISTANT_READ_TIMED_OUT;
/** @type {ReturnType<typeof setTimeout> | undefined} */
let timer;
const guard = new Promise((resolve) => {
timer = setTimeout(() => resolve(ASSISTANT_READ_TIMED_OUT), budget);
// Do not hold the event loop open purely for this guard.
if (typeof timer?.unref === 'function') timer.unref();
});
try {
return await Promise.race([
Promise.resolve(task).catch(() => ASSISTANT_READ_TIMED_OUT),
guard,
]);
} finally {
if (timer) clearTimeout(timer);
}
}

/**
* Resolve the effective per-read budget: the smaller of the remaining command
* deadline and the per-read ceiling. Returns 0 when the deadline has passed.
* @param {number} [remainingMs]
* @param {number} [ceilingMs]
* @returns {number}
*/
export function resolveAssistantReadBudgetMs(remainingMs, ceilingMs = ASSISTANT_READ_TIMEOUT_MS) {
const ceiling = Number.isFinite(Number(ceilingMs)) && Number(ceilingMs) > 0
? Number(ceilingMs)
: ASSISTANT_READ_TIMEOUT_MS;
if (remainingMs == null) return ceiling;
const remaining = Number(remainingMs);
if (!Number.isFinite(remaining) || remaining <= 0) return 0;
return Math.min(ceiling, remaining);
}

/**
* Browser-context helper. Keep this self-contained so Playwright can serialize
* it into page.evaluate without relying on module closures.
Expand Down Expand Up @@ -39,6 +100,41 @@ export function readTopLevelAssistantTexts(selectors) {
return [];
}

/**
* Browser-context helper: count top-level assistant turns and serialize only the
* turns at/after `minIndex`. Long conversations otherwise pay `innerText` on
* every historical turn on every 500ms poll tick even though only the newest
* answer matters (#88). Keep self-contained for page.evaluate serialization.
* @param {{ selectors: string[], minIndex: number }} input
* @returns {{ total: number, texts: string[] }}
*/
export function readAssistantTextsAfterIndex(input) {
const activeSelectors = Array.isArray(input && input.selectors) && input.selectors.length
? input.selectors
: [
'[data-message-author-role="assistant"]',
'[data-turn="assistant"]',
'article[data-testid^="conversation-turn"]',
];
const rawMin = Number(input && input.minIndex);
const minIndex = Number.isFinite(rawMin) && rawMin > 0 ? Math.floor(rawMin) : 0;
const isInsideAnotherMatchedNode = (/** @type {any} */ el, /** @type {any[]} */ matched) =>
matched.some(other => other !== el && typeof other.contains === 'function' && other.contains(el));

for (const selector of activeSelectors) {
const matched = Array.from(document.querySelectorAll(selector));
const topLevel = matched.filter(el => !isInsideAnotherMatchedNode(el, matched));
if (!topLevel.length) continue;
// Read text only for the tail we actually need.
const texts = topLevel
.slice(minIndex)
.map(el => String((/** @type {any} */ (el)).innerText || el.textContent || '').trim())
.filter(Boolean);
return { total: topLevel.length, texts };
}
return { total: 0, texts: [] };
}

/**
* Fallback path for environments where page.evaluate fails but Playwright
* locators still work. It applies the same descendant de-duplication rule as
Expand Down
21 changes: 14 additions & 7 deletions web-ai/chatgpt-response-observer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
import {
CHATGPT_ASSISTANT_SELECTORS,
CHATGPT_STOP_SELECTORS,
ASSISTANT_READ_TIMED_OUT,
readTopLevelAssistantTexts,
resolveAssistantReadBudgetMs,
withAssistantReadTimeout,
} from './chatgpt-response-dom.mjs';

const DEFAULT_QUIET_MS = 1_200;
Expand Down Expand Up @@ -90,18 +93,22 @@ export async function observeAssistantResponse(page, { baselineAssistantCount =
* rejecting placeholders via the injected `isFinalAnswer` predicate. Read-only;
* never throws. Returns `null` when there is no usable final answer.
* @param {{ evaluate: Function, waitForTimeout?: Function, locator?: Function }} page
* @param {{ baselineAssistantCount?: number, isFinalAnswer?: (text: string) => boolean, readStreaming?: () => Promise<boolean>|boolean, readFinished?: () => Promise<boolean>|boolean, stabilityWindowMs?: number }} [opts]
* @param {{ baselineAssistantCount?: number, isFinalAnswer?: (text: string) => boolean, readStreaming?: () => Promise<boolean>|boolean, readFinished?: () => Promise<boolean>|boolean, stabilityWindowMs?: number, readTimeoutMs?: number }} [opts]
* @returns {Promise<{ from: 'recovery', text: string, recovered: true, streaming: boolean, finished: boolean, responseStableMs: number } | null>}
*/
export async function recoverAssistantResponse(page, { baselineAssistantCount = 0, isFinalAnswer, readStreaming, readFinished, stabilityWindowMs } = {}) {
export async function recoverAssistantResponse(page, { baselineAssistantCount = 0, isFinalAnswer, readStreaming, readFinished, stabilityWindowMs, readTimeoutMs } = {}) {
const minIdx = Math.max(0, Math.floor(Number(baselineAssistantCount) || 0));
const readBudgetMs = resolveAssistantReadBudgetMs(readTimeoutMs);
const readCandidates = async () => {
let texts;
try {
texts = await page.evaluate(readTopLevelAssistantTexts, CHATGPT_ASSISTANT_SELECTORS);
} catch {
return [];
}
// Recovery runs right after a poll timeout, so an unbounded read here
// would re-hang the command it is supposed to rescue (#88).
const evaluated = await withAssistantReadTimeout(
Promise.resolve().then(() => page.evaluate(readTopLevelAssistantTexts, CHATGPT_ASSISTANT_SELECTORS)),
readBudgetMs,
);
if (evaluated === ASSISTANT_READ_TIMED_OUT) return [];
texts = evaluated;
if (!Array.isArray(texts) || !texts.length) return [];
return texts.slice(minIdx).filter(text => {
if (!text) return false;
Expand Down
Loading