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
20 changes: 10 additions & 10 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-28.

| 경로 | 파일 수 | 라인 수 | 역할 |
| --- | ---: | ---: | --- |
Expand All @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
29 changes: 29 additions & 0 deletions test/integration/web-ai-cli-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alias>');
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');
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;
}
});
});
161 changes: 161 additions & 0 deletions test/unit/web-ai-chatgpt-family-wiring.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading