diff --git a/structure/str_func.md b/structure/str_func.md index a40813bd..c382ce9d 100644 --- a/structure/str_func.md +++ b/structure/str_func.md @@ -16,7 +16,7 @@ aliases: [agbrowse source map, agbrowse str_func, agbrowse 파일 구조] ## 현재 구조 스냅샷 -마지막 측정: 2026-07-11. +마지막 측정: 2026-07-28. | 경로 | 파일 수 | 라인 수 | 역할 | | --- | ---: | ---: | --- | @@ -25,14 +25,14 @@ 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 | 27754 | 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/` | 12 | 1805 | eval runner, release scripts, named release gates, strict-baseline / module-graph / bin smoke checks, postinstall star prompt (inline yes/no selector + agent detection) | -| `test/unit/` | 142 | 17803 | deterministic module tests | -| `test/integration/` | 21 | 3167 | CLI, MCP, policy, provider fixture tests | +| `scripts/` | 12 | 1805 | eval runner, release scripts, named release gates, strict-baseline / module-graph / bin smoke checks | +| `test/unit/` | 144 | 18137 | deterministic module tests | +| `test/integration/` | 21 | 3196 | CLI, MCP, policy, provider fixture tests | | `test/e2e/` | 1 | 50 | browser smoke coverage | | `test/spec/` | 2 | 35 | high-level contract specs | | `docs/` | 41 | 3540 | adoption, trace, production-readiness, comparison, benchmark, EXTERNAL_CDP, migration docs, GitHub Pages developer docs | @@ -80,14 +80,14 @@ aliases: [agbrowse source map, agbrowse str_func, agbrowse 파일 구조] | `skills/browser/adaptive-fetch/transforms.mjs` | 86 | URL transforms, HTML-to-text, content-type checks | | `skills/browser/adaptive-fetch/third-party-readers.mjs` | 46 | Jina Reader integration | | `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/cli.mjs` | 2043 | `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` | 1225 | 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 | +| `web-ai/mcp-server.mjs` | 479 | stdio JSON-RPC MCP bridge | | `web-ai/tool-schema.mjs` | 208 | MCP and AI SDK schema source | | `web-ai/answer-artifact.mjs` | 153 | provider poll result artifact normalization | | `web-ai/source-audit.mjs` | 183 | claim/source coverage audit helper | diff --git a/test/integration/web-ai-cli-contract.test.mjs b/test/integration/web-ai-cli-contract.test.mjs index e7fb5400..e77543cb 100644 --- a/test/integration/web-ai-cli-contract.test.mjs +++ b/test/integration/web-ai-cli-contract.test.mjs @@ -233,6 +233,35 @@ describe('web-ai CLI contract', () => { expect(result.stderr).toContain('unsupported ChatGPT model selection'); }); + it('accepts every canonical --family alias in CLI preflight', async () => { + for (const family of ['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4', 'gpt-5.3', 'o3']) { + const result = await execBrowser(['web-ai', 'render', '--vendor', 'chatgpt', '--prompt', 'hello', '--family', family]); + expect(result.code, `family ${family} should pass preflight`).toBe(0); + expect(result.stderr).not.toContain('unsupported'); + } + }); + + it('rejects an unsupported --family before any browser mutation', async () => { + const result = await execBrowser(['web-ai', 'render', '--vendor', 'chatgpt', '--prompt', 'hello', '--family', 'gpt-5.6-luna']); + expect(result.code).not.toBe(0); + expect(result.stderr).toContain('unsupported ChatGPT family'); + }); + + it('rejects --family for non-ChatGPT vendors', async () => { + for (const vendor of ['gemini', 'grok']) { + const result = await execBrowser(['web-ai', 'render', '--vendor', vendor, '--prompt', 'hello', '--family', 'gpt-5.6-sol']); + expect(result.code, `${vendor} should reject --family`).not.toBe(0); + expect(result.stderr).toContain('--family is supported only for ChatGPT'); + } + }); + + it('documents --family in web-ai help so docs and parser agree', async () => { + const result = await execBrowser(['web-ai', '--help']); + expect(result.code).toBe(0); + expect(result.stdout).toContain('--family '); + expect(result.stdout).toContain('gpt-5.6-sol | gpt-5.5 | gpt-5.4 | gpt-5.3 | o3'); + }); + it('accepts observed Gemini and Grok model choices in CLI preflight', async () => { const gemini = await execBrowser(['web-ai', 'render', '--vendor', 'gemini', '--prompt', 'hello', '--model', 'thinking']); expect(gemini.stderr).not.toContain('unsupported gemini model selection'); diff --git a/test/unit/web-ai-assistant-read-deadline.test.mjs b/test/unit/web-ai-assistant-read-deadline.test.mjs new file mode 100644 index 00000000..ffa4b9df --- /dev/null +++ b/test/unit/web-ai-assistant-read-deadline.test.mjs @@ -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; + } + }); +}); diff --git a/test/unit/web-ai-chatgpt-family-wiring.test.mjs b/test/unit/web-ai-chatgpt-family-wiring.test.mjs new file mode 100644 index 00000000..1b962978 --- /dev/null +++ b/test/unit/web-ai-chatgpt-family-wiring.test.mjs @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Regression coverage for #87: `--family` was documented by help, README, the +// bundled skill, and the MCP schema, but never reached ChatGPT model selection. +// These tests assert the wiring at the module boundary rather than grepping +// source text, so a refactor that drops the argument fails here. + +// Mutable holder the hoisted mock reads from (vi.mock factories cannot close +// over per-test locals directly). +const captured = { selectCalls: [], probeCalls: [] }; + +vi.mock('../../web-ai/chatgpt-model.mjs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + selectChatGptModel: async (_page, model, options = {}) => { + captured.selectCalls.push({ model, options }); + return { + requested: model || null, + selected: 'thinking', + alreadySelected: true, + effort: options.effort || null, + requestedEffort: options.effort || null, + usedFallbacks: [], + warnings: [], + modelSelection: { + requestedModel: model || null, + resolvedLabel: 'High', + surface: 'chat', + familyLabel: options.family ? 'GPT-5.6 Sol' : null, + tierLabel: 'High', + normalizedModel: 'thinking', + strategy: 'select', + status: 'already-selected', + verified: true, + }, + }; + }, + chatGptModelCapabilityProbe: async (_page, model, options = {}) => { + captured.probeCalls.push({ model, options }); + return { state: 'ok', evidence: {}, next: 'send' }; + }, + }; +}); + +describe('web-ai ChatGPT --family wiring (#87)', () => { + beforeEach(() => { + captured.selectCalls = []; + captured.probeCalls = []; + }); + + it('carries input.family into selectChatGptModel from the send path', async () => { + const { sendWebAi } = await import('../../web-ai/chatgpt.mjs'); + await sendWebAi(createDeps(createFakeChatGptPage()), { + vendor: 'chatgpt', + prompt: 'family wiring check', + family: 'gpt-5.6-sol', + model: 'thinking', + reasoningEffort: 'high', + inlineOnly: true, + timeout: 1, + }).catch(() => undefined); + + expect(captured.selectCalls.length).toBeGreaterThan(0); + expect(captured.selectCalls[0].options.family).toBe('gpt-5.6-sol'); + expect(captured.selectCalls[0].options.effort).toBe('high'); + expect(captured.selectCalls[0].model).toBe('thinking'); + }); + + it('preserves the zero-mutation contract when family is omitted', async () => { + const { sendWebAi } = await import('../../web-ai/chatgpt.mjs'); + await sendWebAi(createDeps(createFakeChatGptPage()), { + vendor: 'chatgpt', + prompt: 'no family', + model: 'thinking', + reasoningEffort: 'high', + inlineOnly: true, + timeout: 1, + }).catch(() => undefined); + + expect(captured.selectCalls.length).toBeGreaterThan(0); + expect(captured.selectCalls[0].options.family).toBeUndefined(); + }); + + it('forwards family into the model capability probe', async () => { + const { statusWebAi } = await import('../../web-ai/chatgpt.mjs'); + await statusWebAi(createDeps(createFakeChatGptPage()), { + vendor: 'chatgpt', + family: 'gpt-5.6-sol', + model: 'thinking', + reasoningEffort: 'high', + }).catch(() => undefined); + + const familyProbe = captured.probeCalls.find(call => call.options.family); + expect(familyProbe?.options.family).toBe('gpt-5.6-sol'); + }); +}); + +describe('ChatGPT family validation fails closed (#87)', () => { + it('probe reports fail for an unsupported family instead of a clean pass', async () => { + const actual = await vi.importActual('../../web-ai/chatgpt-model.mjs'); + const result = await actual.chatGptModelCapabilityProbe(untouchablePage(), 'thinking', { + family: 'gpt-5.6-luna', + }); + expect(result.state).toBe('fail'); + expect(result.next).toBe('model-fallback'); + expect(result.evidence.family).toBe('gpt-5.6-luna'); + }); + + it('selectChatGptModel rejects an unsupported family before touching the page', async () => { + const actual = await vi.importActual('../../web-ai/chatgpt-model.mjs'); + await expect(actual.selectChatGptModel(untouchablePage(), 'thinking', { family: 'gpt-5.6-luna' })) + .rejects.toMatchObject({ errorCode: 'provider.model-mismatch' }); + }); +}); + +function untouchablePage() { + return new Proxy({}, { + get() { + throw new Error('page must not be touched for an invalid family'); + }, + }); +} + +/** Minimal deps stub covering only what the send path touches around selection. */ +function createDeps(page) { + return { + getPage: async () => page, + getTargetId: async () => 'target-family', + getPort: () => 9222, + getCdpSession: async () => ({ + send: async () => undefined, + detach: async () => undefined, + }), + }; +} + +function createFakeChatGptPage() { + const locatorStub = () => ({ + first: () => locatorStub(), + last: () => locatorStub(), + all: async () => [], + filter: () => locatorStub(), + locator: () => locatorStub(), + isVisible: async () => false, + innerText: async () => '', + click: async () => undefined, + boundingBox: async () => null, + count: async () => 0, + }); + return { + url: () => 'https://chatgpt.com/c/family-wiring', + locator: locatorStub, + evaluate: async () => [], + innerText: async () => '', + waitForTimeout: async () => undefined, + keyboard: { insertText: async () => undefined, press: async () => undefined }, + mouse: { move: async () => undefined }, + goto: async () => undefined, + }; +} diff --git a/test/unit/web-ai-chatgpt-model.test.mjs b/test/unit/web-ai-chatgpt-model.test.mjs index db75de24..9c41322e 100644 --- a/test/unit/web-ai-chatgpt-model.test.mjs +++ b/test/unit/web-ai-chatgpt-model.test.mjs @@ -669,14 +669,18 @@ describe('web-ai ChatGPT model selector policy', () => { expect(result.usedFallbacks).toContain('composer-model-pill'); }); - it('wires ChatGPT effort options through the CLI surface', () => { + it('wires ChatGPT family and effort options through the CLI surface', () => { const cliSrc = readFileSync(join(process.cwd(), 'web-ai', 'cli.mjs'), 'utf8'); const chatgptSrc = readFileSync(join(process.cwd(), 'web-ai', 'chatgpt.mjs'), 'utf8'); expect(cliSrc).toContain("effort: { type: 'string' }"); expect(cliSrc).toContain("'reasoning-effort': { type: 'string' }"); expect(cliSrc).toContain('reasoningEffort: values.effort'); - expect(chatgptSrc).toContain("selectChatGptModel(page, input.model, { effort: input.reasoningEffort })"); + // #87: family must be declared by the parser, carried into the input, and + // handed to model selection alongside effort. + expect(cliSrc).toContain("family: { type: 'string' }"); + expect(cliSrc).toContain('family: values.family'); + expect(chatgptSrc).toMatch(/selectChatGptModel\(page, input\.model, \{[\s\S]*?family: input\.family,[\s\S]*?effort: input\.reasoningEffort,[\s\S]*?\}\)/); expect(chatgptSrc).toContain('updateSession(session.sessionId, { modelSelection: selectedModel.modelSelection });'); expect(chatgptSrc).toContain('...(selectedModel?.warnings || [])'); }); diff --git a/test/unit/web-ai-provider-session.test.mjs b/test/unit/web-ai-provider-session.test.mjs index 454a47ff..6a851a07 100644 --- a/test/unit/web-ai-provider-session.test.mjs +++ b/test/unit/web-ai-provider-session.test.mjs @@ -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; diff --git a/test/unit/web-ai-tool-schema.test.mjs b/test/unit/web-ai-tool-schema.test.mjs index f97b1095..feaed3b4 100644 --- a/test/unit/web-ai-tool-schema.test.mjs +++ b/test/unit/web-ai-tool-schema.test.mjs @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { BROWSER_TOOLS, MCP_TOOLS, @@ -72,6 +74,14 @@ describe('web-ai MCP tool schema', () => { ]); }); + it('web_ai_submit_prompt forwards the advertised family into provider send (#87)', async () => { + const mcpSrc = readFileSync(join(process.cwd(), 'web-ai', 'mcp-server.mjs'), 'utf8'); + // The schema advertises family, so the send path must carry it and must + // fail closed for providers without a Chat family axis. + expect(mcpSrc).toContain('family: args.family'); + expect(mcpSrc).toContain("args.family && provider !== 'chatgpt'"); + }); + it('web_ai_submit_prompt effort enum includes canonical and legacy aliases but not max/ultra', () => { const schema = toolSchemaForMcp('web_ai_submit_prompt'); const effortEnum = schema.inputSchema.properties.effort.enum; diff --git a/web-ai/chatgpt-model.mjs b/web-ai/chatgpt-model.mjs index ea7b5962..42146921 100644 --- a/web-ai/chatgpt-model.mjs +++ b/web-ai/chatgpt-model.mjs @@ -1506,6 +1506,7 @@ function escapeRegExp(value) { /** * @typedef {Object} CapabilityProbeOptions + * @property {string} [family] * @property {string} [effort] * @property {string} [reasoningEffort] */ @@ -1526,7 +1527,18 @@ function escapeRegExp(value) { export async function chatGptModelCapabilityProbe(page, model, options = {}) { const requested = normalizeChatGptModelChoice(model); const requestedEffort = normalizeChatGptEffortChoice(options.effort || options.reasoningEffort); - if (!model && !(options.effort || options.reasoningEffort)) return { state: 'unknown', evidence: { requested: null, effort: null }, next: 'send' }; + const requestedFamily = normalizeChatGptFamilyChoice(options.family); + // An unsupported family alias must never look probe-clean: a rendered/probe + // pass is otherwise read as proof the family was enforced (#87). + if (options.family && !requestedFamily) { + return { state: 'fail', evidence: { family: options.family }, next: 'model-fallback' }; + } + if (!model && !(options.effort || options.reasoningEffort) && !requestedFamily) return { state: 'unknown', evidence: { requested: null, effort: null, family: null }, next: 'send' }; + if (!model && requestedFamily) { + // Family-only requests do not need a tier row; report the family axis as + // observable without claiming tier selectability. + return { state: 'unknown', evidence: { requested: null, effort: null, family: requestedFamily }, next: 'send' }; + } if (!requested) return { state: 'fail', evidence: { requested: model }, next: 'model-fallback' }; if ((options.effort || options.reasoningEffort) && !requestedEffort) return { state: 'fail', evidence: { requested, effort: options.effort || options.reasoningEffort }, next: 'model-fallback' }; if (requestedEffort && !isChatGptEffortSupported(requested, requestedEffort)) return { state: 'fail', evidence: { requested, effort: requestedEffort }, next: 'model-fallback' }; @@ -1557,5 +1569,5 @@ export async function chatGptModelCapabilityProbe(page, model, options = {}) { } const selectable = Boolean(option) && (!requestedEffort || Boolean(effortOption)); const state = selectable ? (menuClosed ? 'ok' : 'warn') : 'fail'; - return { state, evidence: { requested, effort: requestedEffort || null, menuClosed, usedFallbacks }, next: state === 'ok' ? 'send' : 'model-fallback' }; + return { state, evidence: { requested, effort: requestedEffort || null, family: requestedFamily || null, menuClosed, usedFallbacks }, next: state === 'ok' ? 'send' : 'model-fallback' }; } diff --git a/web-ai/chatgpt-response-dom.mjs b/web-ai/chatgpt-response-dom.mjs index dc5add59..0c577fe4 100644 --- a/web-ai/chatgpt-response-dom.mjs +++ b/web-ai/chatgpt-response-dom.mjs @@ -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"]', @@ -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} task + * @param {number} timeoutMs + * @returns {Promise} + */ +export async function withAssistantReadTimeout(task, timeoutMs) { + const budget = Number(timeoutMs); + if (!Number.isFinite(budget) || budget <= 0) return ASSISTANT_READ_TIMED_OUT; + /** @type {ReturnType | 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. @@ -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 diff --git a/web-ai/chatgpt-response-observer.mjs b/web-ai/chatgpt-response-observer.mjs index b6b946c3..3e13757f 100644 --- a/web-ai/chatgpt-response-observer.mjs +++ b/web-ai/chatgpt-response-observer.mjs @@ -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; @@ -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, readFinished?: () => Promise|boolean, stabilityWindowMs?: number }} [opts] + * @param {{ baselineAssistantCount?: number, isFinalAnswer?: (text: string) => boolean, readStreaming?: () => Promise|boolean, readFinished?: () => Promise|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; diff --git a/web-ai/chatgpt.mjs b/web-ai/chatgpt.mjs index 87d6b221..23d3c97e 100644 --- a/web-ai/chatgpt.mjs +++ b/web-ai/chatgpt.mjs @@ -54,8 +54,12 @@ import { buildTargetMismatchResult } from './session-target-guard.mjs'; import { CHATGPT_ASSISTANT_SELECTORS, CHATGPT_STOP_SELECTORS, + ASSISTANT_READ_TIMED_OUT, + readAssistantTextsAfterIndex, readTopLevelAssistantTexts, readTopLevelAssistantTextsFromLocators, + resolveAssistantReadBudgetMs, + withAssistantReadTimeout, } from './chatgpt-response-dom.mjs'; const CHATGPT_HOSTS = new Set(['chatgpt.com', 'chat.openai.com']); @@ -109,7 +113,7 @@ export async function renderWebAi(input = {}) { export const chatGptCapabilities = [ defineCapability('chatgpt-active-tab-verification', async (/** @type {any} */ deps) => probeHostMatches(await deps.getPage(), CHATGPT_HOSTS)), defineCapability('chatgpt-composer-visible', async (/** @type {any} */ deps) => probeFirstVisibleSelector(await deps.getPage(), CHATGPT_COMPOSER_SELECTORS)), - defineCapability('chatgpt-model-alias-selectable', async (/** @type {any} */ deps, /** @type {any} */ input) => chatGptModelCapabilityProbe(await deps.getPage(), input.model, { effort: input.reasoningEffort })), + defineCapability('chatgpt-model-alias-selectable', async (/** @type {any} */ deps, /** @type {any} */ input) => chatGptModelCapabilityProbe(await deps.getPage(), input.model, { family: input.family, effort: input.reasoningEffort })), defineCapability('chatgpt-upload-surface-visible', async (/** @type {any} */ deps, /** @type {any} */ input) => { if (!input.filePath && input.inlineOnly !== false) return { state: 'unknown', evidence: { required: false }, next: 'send' }; return probeFirstVisibleSelector(await deps.getPage(), CHATGPT_UPLOAD_SELECTORS, { failNext: 'inline-only' }); @@ -176,7 +180,10 @@ export async function sendWebAi(deps, input = {}) { ? renderQuestionEnvelopeWithContext(envelope, contextPack.composerText) : renderQuestionEnvelope(envelope) : renderQuestionEnvelope(envelope); - const selectedModel = await selectChatGptModel(page, input.model, { effort: input.reasoningEffort }); + const selectedModel = await selectChatGptModel(page, input.model, { + family: input.family, + effort: input.reasoningEffort, + }); await waitForStableAssistantCount(page); const assistantCount = await countAssistantMessages(page); @@ -363,6 +370,9 @@ export async function pollWebAi(deps, input = {}) { let stableText = ''; let stableSince = 0; let lastHeartbeat = 0; + // Counts assistant-DOM reads that exceeded their budget, so a stalled read is + // reported distinctly from "provider is still streaming" (#88). + let domReadTimeouts = 0; // 33 short-circuit: a MutationObserver wakes the loop as soon as the response // settles (bounded so it self-disconnects). The poller stays AUTHORITATIVE — // it still reads + verifies every tick; this only reduces wait latency, so the @@ -398,8 +408,32 @@ export async function pollWebAi(deps, input = {}) { }; } } - const answers = await readAssistantMessages(page); - const newAnswers = answers.slice(baseline.assistantCount).filter(isFinalAnswer); + // Bound every DOM read by the time actually left, so a stalled evaluate + // cannot hold the loop past `deadline` (#88). + const readBudgetMs = deadline - Date.now(); + // Out of budget is the deadline itself, not a stalled read; leave the + // loop so the timeout path is not mislabeled as a DOM-read failure. + if (readBudgetMs <= 0) break; + const read = await readAssistantMessagesAfterBaseline(page, { + remainingMs: readBudgetMs, + minIndex: baseline.assistantCount, + }); + if (read.timedOut) { + domReadTimeouts += 1; + // Keep emitting liveness while reads stall. Silence here was the + // original symptom: the process looked alive with no output (#88). + const stalledAt = Date.now(); + if (stalledAt - lastHeartbeat >= 30_000) { + const elapsed = Math.round((stalledAt - startedAt) / 1000); + process.stderr.write(`[poll] ${elapsed}s — assistant DOM read timed out (${domReadTimeouts}x); retrying...\n`); + lastHeartbeat = stalledAt; + } + // Re-check the outer deadline immediately instead of blocking here. + if (Date.now() > deadline) break; + await page.waitForTimeout(500).catch(() => undefined); + continue; + } + const newAnswers = read.newAnswers.filter(isFinalAnswer); const latest = newAnswers.at(-1) || ''; const streaming = await isStreaming(page); const now = Date.now(); @@ -559,6 +593,10 @@ export async function pollWebAi(deps, input = {}) { isFinalAnswer, readStreaming: () => isStreaming(page), readFinished: () => isResponseFinished(page), + // Reads were already stalling during the poll; keep the rescue read + // short so recovery cannot extend the command by another full + // per-read ceiling (#88). + ...(domReadTimeouts > 0 ? { readTimeoutMs: 2_000 } : {}), }); if (recovered?.text) { if (recovered.streaming === true) { @@ -683,7 +721,9 @@ export async function pollWebAi(deps, input = {}) { ...(timedOutSession?.deadlineAt ? { deadlineAt: timedOutSession.deadlineAt } : {}), ...(timedOutSession?.conversationUrl ? { conversationUrl: timedOutSession.conversationUrl } : {}), baseline, - warnings: [], + // Distinguish "provider never finished" from "we could not read the DOM + // in time"; the two need different operator responses (#88). + warnings: domReadTimeouts > 0 ? [`assistant-dom-read-timeout:${domReadTimeouts}`] : [], usedFallbacks: [], recoverable: true, retryHint: 'poll-or-resume', @@ -1002,9 +1042,10 @@ function persistResolverTraceForSession(session, traceCtx) { /** * @param {any} page + * @param {number} [remainingMs] */ -async function countAssistantMessages(page) { - return (await readAssistantMessages(page)).length; +async function countAssistantMessages(page, remainingMs) { + return (await readAssistantMessages(page, { remainingMs })).length; } /** @@ -1016,7 +1057,7 @@ async function waitForStableAssistantCount(page, timeoutMs = 8_000) { let previous = -1; let stableReads = 0; while (Date.now() < deadline) { - const count = await countAssistantMessages(page).catch(() => 0); + const count = await countAssistantMessages(page, deadline - Date.now()).catch(() => 0); if (count === previous) stableReads += 1; else stableReads = 0; previous = count; @@ -1026,15 +1067,89 @@ async function waitForStableAssistantCount(page, timeoutMs = 8_000) { } /** + * Read top-level assistant turns, bounded by the caller's remaining deadline. + * + * Playwright's `page.evaluate()` has no timeout option, so an evaluation that + * stalls (huge conversation, blocked main thread) used to suspend the poll loop + * indefinitely — the outer deadline was only re-checked at the loop boundary + * (#88). Every read is now raced against the remaining budget, and a read that + * exceeds it reports `timedOut` instead of parking the command. + * * @param {any} page + * @param {{ remainingMs?: number, minIndex?: number }} [options] + * @returns {Promise} */ -async function readAssistantMessages(page) { - const evaluated = await page.evaluate(readTopLevelAssistantTexts, ASSISTANT_SELECTORS).catch(() => []); +async function readAssistantMessages(page, options = {}) { + const budgetMs = resolveAssistantReadBudgetMs(options.remainingMs); + if (budgetMs <= 0) return markTimedOut([]); + const evaluated = await withAssistantReadTimeout( + Promise.resolve().then(() => page.evaluate(readTopLevelAssistantTexts, ASSISTANT_SELECTORS)), + budgetMs, + ); + if (evaluated === ASSISTANT_READ_TIMED_OUT) return markTimedOut([]); if (Array.isArray(evaluated) && evaluated.length) return evaluated.map(cleanAssistantText).filter(Boolean); - const fallback = await readTopLevelAssistantTextsFromLocators(page, ASSISTANT_SELECTORS); + // The locator fallback issues one round trip per turn, so bound it by the + // budget that is actually left after the evaluate attempt. + const fallback = await withAssistantReadTimeout( + Promise.resolve().then(() => readTopLevelAssistantTextsFromLocators(page, ASSISTANT_SELECTORS)), + resolveAssistantReadBudgetMs(options.remainingMs), + ); + if (fallback === ASSISTANT_READ_TIMED_OUT) return markTimedOut([]); return fallback.map(cleanAssistantText).filter(Boolean); } +/** + * Poll-tick read: count all turns in-page but serialize only the turns after the + * baseline. Avoids re-serializing the whole conversation every 500ms (#88). + * Falls back to the full read when the trimmed path is unavailable or times out. + * @param {any} page + * @param {{ remainingMs?: number, minIndex?: number }} options + * @returns {Promise<{ newAnswers: string[], total: number, timedOut: boolean }>} + */ +async function readAssistantMessagesAfterBaseline(page, options = {}) { + const minIndex = Math.max(0, Math.floor(Number(options.minIndex) || 0)); + const budgetMs = resolveAssistantReadBudgetMs(options.remainingMs); + if (budgetMs <= 0) return { newAnswers: [], total: 0, timedOut: true }; + const trimmed = await withAssistantReadTimeout( + Promise.resolve().then(() => page.evaluate(readAssistantTextsAfterIndex, { + selectors: ASSISTANT_SELECTORS, + minIndex, + })), + budgetMs, + ); + if (trimmed === ASSISTANT_READ_TIMED_OUT) return { newAnswers: [], total: 0, timedOut: true }; + // Only trust the trimmed shape when it actually observed turns. A page that + // cannot serialize the object argument (older stubs, restricted contexts) + // reports zero turns; fall back to the full read so behavior is unchanged. + if (trimmed && typeof trimmed === 'object' && Array.isArray((/** @type {any} */ (trimmed)).texts) + && Number((/** @type {any} */ (trimmed)).total) > 0) { + const result = /** @type {{ total: number, texts: string[] }} */ (trimmed); + return { + newAnswers: result.texts.map(cleanAssistantText).filter(Boolean), + total: Number(result.total) || 0, + timedOut: false, + }; + } + // No turns observed through the trimmed path (or a legacy array shape came + // back): reuse the full read, which also covers the locator fallback. + const answers = await readAssistantMessages(page, { remainingMs: options.remainingMs }); + return { + newAnswers: answers.slice(minIndex), + total: answers.length, + timedOut: answers.timedOut === true, + }; +} + +/** + * Tag an assistant read as deadline-exceeded while keeping the array shape all + * existing callers expect. + * @param {string[]} texts + * @returns {string[] & { timedOut?: boolean }} + */ +function markTimedOut(texts) { + return Object.assign(texts, { timedOut: true }); +} + /** * @param {{ vendor: string, page: any, session: any, baseline: any, answerText: string, usedFallbacks: string[], warning: string, streamingState: string }} input */ diff --git a/web-ai/cli.mjs b/web-ai/cli.mjs index fd478894..74778a6b 100644 --- a/web-ai/cli.mjs +++ b/web-ai/cli.mjs @@ -17,7 +17,7 @@ import { maybeRecordChurn } from './churn-log.mjs'; import { watchSession } from './watcher.mjs'; import { buildWebAiSnapshot } from './ax-snapshot.mjs'; import { runSessionsCommand, printSessionsHuman, parseDurationToMs } from './cli-sessions.mjs'; -import { isChatGptEffortSupported, normalizeChatGptFamilyChoice } from './chatgpt-model.mjs'; +import { CHATGPT_FAMILY_OPTIONS, isChatGptEffortSupported, normalizeChatGptFamilyChoice } from './chatgpt-model.mjs'; import { createTab, listManagedTabs, waitForPageByTargetId } from '../skills/browser/tab-manager.mjs'; import { cleanupIdleTabs, isPinned, DEFAULT_MAX_TABS } from '../skills/browser/tab-lifecycle.mjs'; import { resolveSessionPage, withSessionPage } from './tab-recovery.mjs'; @@ -116,6 +116,11 @@ Provider: Gemini models: flash-lite, flash, pro Gemini tool: deepthink Grok: auto, fast, expert, thinking, heavy + --family ChatGPT Chat family (independent of the --model tier): + gpt-5.6-sol | gpt-5.5 | gpt-5.4 | gpt-5.3 | o3 + Omit to preserve the family currently selected in the UI + (zero submenu mutation). An unsupported alias fails closed + before any browser mutation. ChatGPT only. --effort ChatGPT reasoning effort. The reasoning-effort menu is ONLY touched when this flag is provided; otherwise the currently-checked effort in the browser is left as-is. @@ -586,6 +591,7 @@ async function runWebAiCliInner(argv = [], deps) { archive: { type: 'string' }, 'follow-up': { type: 'string', multiple: true }, model: { type: 'string' }, + family: { type: 'string' }, effort: { type: 'string' }, 'reasoning-effort': { type: 'string' }, 'thinking-time': { type: 'string' }, @@ -690,6 +696,7 @@ async function runWebAiCliInner(argv = [], deps) { followUps: values['follow-up'] || [], thinkingTime: values['thinking-time'], model: values.model, + family: values.family, reasoningEffort: values.effort || values['reasoning-effort'], contextFromFiles: values['context-from-files'] || [], contextExclude: values['context-exclude'] || [], @@ -1583,6 +1590,32 @@ function rejectFutureScope(values) { evidence: { model: values.model }, }); } + // Chat family is a ChatGPT-only axis and is validated before any browser + // mutation so an unsupported alias cannot be silently ignored (#87). + if (values.family) { + const vendorKey = String(values.vendor || 'chatgpt'); + if (vendorKey !== 'chatgpt') { + throw new WebAiError({ + errorCode: 'capability.unsupported', + stage: 'provider-select-mode', + vendor: vendorKey, + retryHint: 'omit-family-or-use-chatgpt', + message: `--family is supported only for ChatGPT; ${webAiVendorLabel(vendorKey)} has no Chat family axis`, + evidence: { family: values.family, vendor: vendorKey }, + mutationAllowed: false, + }); + } + if (!normalizeChatGptFamilyChoice(values.family)) { + throw new WebAiError({ + errorCode: 'provider.model-mismatch', + stage: 'provider-select-mode', + vendor: 'chatgpt', + retryHint: 'model-fallback', + message: `unsupported ChatGPT family: ${values.family}`, + evidence: { family: values.family, supported: Object.keys(CHATGPT_FAMILY_OPTIONS) }, + }); + } + } const effort = values.effort || values['reasoning-effort']; if (effort && !values.model) { throw new WebAiError({ diff --git a/web-ai/mcp-server.mjs b/web-ai/mcp-server.mjs index 34d7fabb..cffa4876 100644 --- a/web-ai/mcp-server.mjs +++ b/web-ai/mcp-server.mjs @@ -200,6 +200,17 @@ async function callMcpTool(name, args, deps, state) { retryHint: 'use-work-send', }; } + // family is a ChatGPT-only axis. Fail closed instead of forwarding it to + // a provider that cannot honor it (#87). + if (args.family && provider !== 'chatgpt') { + return { + ok: false, + code: 'capability.unsupported', + tool: name, + reason: `family is supported only for ChatGPT; ${provider} has no Chat family axis.`, + retryHint: 'omit-family-or-use-chatgpt', + }; + } const rawPolicyKeys = new Set(Object.keys(args.policy === undefined ? {} : args.policy)); const effectivePolicy = applyProviderDefaults(provider, policy, { explicitKeys: rawPolicyKeys }); enforcePolicy(effectivePolicy, { @@ -216,6 +227,7 @@ async function callMcpTool(name, args, deps, state) { vendor: provider, inlineOnly: args.inlineOnly !== false, attachmentPolicy: 'inline-only', + family: args.family, reasoningEffort: args.effort || args.reasoningEffort, })), );