codex-chatgpt-control 0.3.0-alpha.1 → 0.5.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +42 -2
- package/contracts/v1/fixtures/backend-capabilities.json +10 -0
- package/contracts/v1/fixtures/command-descriptors.json +265 -8
- package/contracts/v1/fixtures/describe-runner-run.json +5 -2
- package/contracts/v1/fixtures/doctor-scenario-preflight.json +34 -2
- package/contracts/v1/fixtures/help-root.json +1 -1
- package/contracts/v1/fixtures/output-json-parse-success.json +9 -0
- package/contracts/v1/fixtures/run-timeout-partial.json +15 -4
- package/contracts/v1/fixtures/stream-in-progress.ndjson +3 -0
- package/contracts/v1/fixtures/stream-submitted-completed.ndjson +1 -1
- package/contracts/v1/fixtures/surface-chat-legacy.json +35 -0
- package/contracts/v1/fixtures/surface-chat-simplified.json +37 -0
- package/contracts/v1/fixtures/surface-sidebar-false-positive.json +29 -0
- package/contracts/v1/fixtures/surface-work-advanced.json +45 -0
- package/contracts/v1/fixtures/surface-work-basic.json +38 -0
- package/contracts/v1/fixtures/workflow-ask-success.json +8 -0
- package/contracts/v1/manifest.json +32 -1
- package/contracts/v1/parity-suite.json +124 -0
- package/contracts/v1/schemas/backend-request.schema.json +15 -0
- package/contracts/v1/schemas/manifest.schema.json +6 -3
- package/contracts/v1/schemas/surface-profile.schema.json +166 -0
- package/dist/codex-chatgpt-control-backend.mjs +3475 -367
- package/dist/codex-chatgpt-control.bundle.mjs +5249 -2102
- package/dist/src/backend/client.d.ts +26 -1
- package/dist/src/backend/client.js +23 -1
- package/dist/src/backend/protocol.d.ts +1 -1
- package/dist/src/backend/protocol.js +10 -0
- package/dist/src/backend/session.js +20 -0
- package/dist/src/browser/attach.d.ts +1 -0
- package/dist/src/browser/attach.js +3 -3
- package/dist/src/browser/downloads.d.ts +1 -1
- package/dist/src/browser/downloads.js +13 -4
- package/dist/src/browser/page-state.js +5 -2
- package/dist/src/client.d.ts +31 -1
- package/dist/src/client.js +69 -14
- package/dist/src/commands/artifacts.js +1 -7
- package/dist/src/commands/configuration.d.ts +15 -0
- package/dist/src/commands/configuration.js +897 -0
- package/dist/src/commands/doctor.js +38 -1
- package/dist/src/commands/experience.d.ts +13 -0
- package/dist/src/commands/experience.js +421 -0
- package/dist/src/commands/files.js +160 -10
- package/dist/src/commands/messages.d.ts +2 -1
- package/dist/src/commands/messages.js +138 -16
- package/dist/src/commands/modes.js +28 -14
- package/dist/src/commands/registry.js +74 -7
- package/dist/src/commands/response-actions.js +1 -7
- package/dist/src/commands/sequence.js +24 -1
- package/dist/src/commands/session.d.ts +2 -0
- package/dist/src/commands/session.js +50 -1
- package/dist/src/commands/threads.js +1 -7
- package/dist/src/commands/work.d.ts +6 -0
- package/dist/src/commands/work.js +338 -0
- package/dist/src/dom/locale/am.d.ts +15 -1
- package/dist/src/dom/locale/am.js +15 -1
- package/dist/src/dom/locale/ar.d.ts +15 -1
- package/dist/src/dom/locale/ar.js +18 -4
- package/dist/src/dom/locale/bg.d.ts +15 -1
- package/dist/src/dom/locale/bg.js +18 -4
- package/dist/src/dom/locale/bn.d.ts +15 -1
- package/dist/src/dom/locale/bn.js +15 -1
- package/dist/src/dom/locale/bs.d.ts +14 -1
- package/dist/src/dom/locale/bs.js +18 -5
- package/dist/src/dom/locale/ca.d.ts +14 -1
- package/dist/src/dom/locale/ca.js +19 -6
- package/dist/src/dom/locale/cs.d.ts +14 -1
- package/dist/src/dom/locale/cs.js +14 -1
- package/dist/src/dom/locale/da.d.ts +14 -1
- package/dist/src/dom/locale/da.js +15 -2
- package/dist/src/dom/locale/de.d.ts +16 -3
- package/dist/src/dom/locale/de.js +18 -5
- package/dist/src/dom/locale/el.d.ts +15 -1
- package/dist/src/dom/locale/el.js +18 -4
- package/dist/src/dom/locale/en.d.ts +27 -2
- package/dist/src/dom/locale/en.js +28 -2
- package/dist/src/dom/locale/es-419.d.ts +15 -1
- package/dist/src/dom/locale/es-419.js +15 -1
- package/dist/src/dom/locale/es-ES.d.ts +15 -1
- package/dist/src/dom/locale/es-ES.js +15 -1
- package/dist/src/dom/locale/et.d.ts +14 -1
- package/dist/src/dom/locale/et.js +14 -1
- package/dist/src/dom/locale/fa.d.ts +14 -1
- package/dist/src/dom/locale/fa.js +16 -3
- package/dist/src/dom/locale/fi.d.ts +15 -1
- package/dist/src/dom/locale/fi.js +15 -1
- package/dist/src/dom/locale/fr-CA.d.ts +13 -1
- package/dist/src/dom/locale/fr-CA.js +13 -1
- package/dist/src/dom/locale/fr-FR.d.ts +16 -3
- package/dist/src/dom/locale/fr-FR.js +20 -7
- package/dist/src/dom/locale/gu.d.ts +15 -1
- package/dist/src/dom/locale/gu.js +19 -5
- package/dist/src/dom/locale/hi.d.ts +15 -1
- package/dist/src/dom/locale/hi.js +17 -3
- package/dist/src/dom/locale/hr.d.ts +14 -1
- package/dist/src/dom/locale/hr.js +17 -4
- package/dist/src/dom/locale/hu.d.ts +15 -1
- package/dist/src/dom/locale/hu.js +18 -4
- package/dist/src/dom/locale/hy.d.ts +15 -1
- package/dist/src/dom/locale/hy.js +18 -4
- package/dist/src/dom/locale/id.d.ts +14 -1
- package/dist/src/dom/locale/id.js +16 -3
- package/dist/src/dom/locale/index.d.ts +17 -2
- package/dist/src/dom/locale/index.js +65 -0
- package/dist/src/dom/locale/is.d.ts +14 -1
- package/dist/src/dom/locale/is.js +14 -1
- package/dist/src/dom/locale/it.d.ts +13 -1
- package/dist/src/dom/locale/it.js +17 -5
- package/dist/src/dom/locale/ja.d.ts +15 -1
- package/dist/src/dom/locale/ja.js +19 -5
- package/dist/src/dom/locale/ka.d.ts +15 -1
- package/dist/src/dom/locale/ka.js +15 -1
- package/dist/src/dom/locale/kk.d.ts +15 -1
- package/dist/src/dom/locale/kk.js +17 -3
- package/dist/src/dom/locale/kn.d.ts +15 -1
- package/dist/src/dom/locale/kn.js +19 -5
- package/dist/src/dom/locale/ko.d.ts +13 -1
- package/dist/src/dom/locale/ko.js +13 -1
- package/dist/src/dom/locale/lt.d.ts +15 -1
- package/dist/src/dom/locale/lt.js +17 -3
- package/dist/src/dom/locale/lv.d.ts +15 -1
- package/dist/src/dom/locale/lv.js +15 -1
- package/dist/src/dom/locale/mk.d.ts +15 -1
- package/dist/src/dom/locale/mk.js +19 -5
- package/dist/src/dom/locale/ml.d.ts +15 -1
- package/dist/src/dom/locale/ml.js +17 -3
- package/dist/src/dom/locale/mn.d.ts +15 -1
- package/dist/src/dom/locale/mn.js +15 -1
- package/dist/src/dom/locale/mr.d.ts +15 -1
- package/dist/src/dom/locale/mr.js +17 -3
- package/dist/src/dom/locale/ms.d.ts +13 -1
- package/dist/src/dom/locale/ms.js +13 -1
- package/dist/src/dom/locale/my.d.ts +15 -1
- package/dist/src/dom/locale/my.js +17 -3
- package/dist/src/dom/locale/nb.d.ts +14 -1
- package/dist/src/dom/locale/nb.js +14 -1
- package/dist/src/dom/locale/nl.d.ts +14 -1
- package/dist/src/dom/locale/nl.js +16 -3
- package/dist/src/dom/locale/pa.d.ts +15 -1
- package/dist/src/dom/locale/pa.js +19 -5
- package/dist/src/dom/locale/pl.d.ts +14 -1
- package/dist/src/dom/locale/pl.js +16 -3
- package/dist/src/dom/locale/pt-BR.d.ts +15 -1
- package/dist/src/dom/locale/pt-BR.js +17 -3
- package/dist/src/dom/locale/pt-PT.d.ts +15 -1
- package/dist/src/dom/locale/pt-PT.js +19 -5
- package/dist/src/dom/locale/ro.d.ts +13 -1
- package/dist/src/dom/locale/ro.js +16 -4
- package/dist/src/dom/locale/ru.d.ts +17 -1
- package/dist/src/dom/locale/ru.js +18 -2
- package/dist/src/dom/locale/sk.d.ts +14 -1
- package/dist/src/dom/locale/sk.js +16 -3
- package/dist/src/dom/locale/sl.d.ts +14 -1
- package/dist/src/dom/locale/sl.js +17 -4
- package/dist/src/dom/locale/so.d.ts +15 -1
- package/dist/src/dom/locale/so.js +17 -3
- package/dist/src/dom/locale/sq.d.ts +13 -1
- package/dist/src/dom/locale/sq.js +17 -5
- package/dist/src/dom/locale/sr.d.ts +17 -1
- package/dist/src/dom/locale/sr.js +18 -2
- package/dist/src/dom/locale/sv.d.ts +14 -1
- package/dist/src/dom/locale/sv.js +14 -1
- package/dist/src/dom/locale/sw.d.ts +15 -1
- package/dist/src/dom/locale/sw.js +15 -1
- package/dist/src/dom/locale/ta.d.ts +15 -1
- package/dist/src/dom/locale/ta.js +19 -5
- package/dist/src/dom/locale/te.d.ts +15 -1
- package/dist/src/dom/locale/te.js +19 -5
- package/dist/src/dom/locale/th.d.ts +7 -1
- package/dist/src/dom/locale/th.js +7 -1
- package/dist/src/dom/locale/tl.d.ts +11 -5
- package/dist/src/dom/locale/tl.js +11 -5
- package/dist/src/dom/locale/tr.d.ts +14 -1
- package/dist/src/dom/locale/tr.js +16 -3
- package/dist/src/dom/locale/types.d.ts +18 -1
- package/dist/src/dom/locale/uk.d.ts +15 -1
- package/dist/src/dom/locale/uk.js +17 -3
- package/dist/src/dom/locale/ur.d.ts +15 -1
- package/dist/src/dom/locale/ur.js +18 -4
- package/dist/src/dom/locale/vi.d.ts +15 -1
- package/dist/src/dom/locale/vi.js +18 -4
- package/dist/src/dom/locale/zh-HK.d.ts +15 -1
- package/dist/src/dom/locale/zh-HK.js +17 -3
- package/dist/src/dom/locale/zh-Hans.d.ts +15 -1
- package/dist/src/dom/locale/zh-Hans.js +19 -5
- package/dist/src/dom/locale/zh-TW.d.ts +15 -1
- package/dist/src/dom/locale/zh-TW.js +17 -3
- package/dist/src/dom/menus.js +26 -2
- package/dist/src/dom/selectors.js +6 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/platform/local-paths.d.ts +1 -0
- package/dist/src/platform/local-paths.js +7 -3
- package/dist/src/runner/responses.d.ts +3 -1
- package/dist/src/runner/responses.js +16 -1
- package/dist/src/runner/result.js +129 -4
- package/dist/src/runner/stream.d.ts +1 -1
- package/dist/src/runner/stream.js +6 -0
- package/dist/src/runner/types.d.ts +35 -1
- package/dist/src/safety/risk.d.ts +10 -0
- package/dist/src/safety/risk.js +10 -0
- package/dist/src/scripts/apply-intelligence-locale-captures.d.ts +15 -0
- package/dist/src/scripts/apply-intelligence-locale-captures.js +225 -7
- package/dist/src/scripts/capture-intelligence-locales.d.ts +8 -0
- package/dist/src/scripts/capture-intelligence-locales.js +663 -6
- package/dist/src/scripts/capture-surface-profile.d.ts +25 -0
- package/dist/src/scripts/capture-surface-profile.js +354 -0
- package/dist/src/scripts/live-smoke/scenarios.d.ts +2 -0
- package/dist/src/scripts/live-smoke/scenarios.js +347 -5
- package/dist/src/scripts/live-smoke.js +2 -0
- package/dist/src/scripts/locale-capture/surface-graph.d.ts +26 -0
- package/dist/src/scripts/locale-capture/surface-graph.js +43 -0
- package/dist/src/scripts/release-canary-module.d.ts +20 -0
- package/dist/src/scripts/release-canary-module.js +86 -0
- package/dist/src/types.d.ts +248 -2
- package/package.json +9 -3
- package/references/2026-07-16-chat-work-surfaces.md +100 -0
- package/references/agents-runner.md +13 -2
- package/references/backend-protocol.md +26 -9
- package/references/language-coverage.md +51 -3
- package/references/localization.md +52 -6
- package/references/python-parity.md +12 -2
- package/references/responses-adapter.md +12 -1
- package/references/troubleshooting.md +38 -4
|
@@ -3,6 +3,7 @@ import { access, stat } from "node:fs/promises";
|
|
|
3
3
|
import { readSystemClipboard } from "../browser/clipboard.js";
|
|
4
4
|
import { readPageState } from "../browser/page-state.js";
|
|
5
5
|
import { localeLabels } from "../dom/locale-labels.js";
|
|
6
|
+
import { localeCoverageSummary } from "../dom/locale/index.js";
|
|
6
7
|
import { BROWSER_BRIDGE_REMEDIATION, resultOk } from "../errors.js";
|
|
7
8
|
import { explainCommandBlocker } from "../diagnostics/blockers.js";
|
|
8
9
|
import { preflightFiles } from "./files.js";
|
|
@@ -233,6 +234,18 @@ function localizationCheck(env) {
|
|
|
233
234
|
const missingModeOptionIds = REQUIRED_MODE_OPTION_IDS.filter(id => (localeLabels.modeOptions[id]?.length ?? 0) === 0);
|
|
234
235
|
const toolIds = Object.keys(localeLabels.tools);
|
|
235
236
|
const modeOptionIds = Object.keys(localeLabels.modeOptions);
|
|
237
|
+
const proAliases = [...localeLabels.modeOptions.pro];
|
|
238
|
+
const supportsProExtendedAlias = proAliases.some(alias => /pro\s*(?:•\s*)?extended/i.test(alias))
|
|
239
|
+
&& proAliases.some(alias => /extended\s+pro/i.test(alias));
|
|
240
|
+
const runningStateLocalizationSupport = runningStateSupport();
|
|
241
|
+
const runningStateLabelCoverage = {
|
|
242
|
+
support: runningStateLocalizationSupport,
|
|
243
|
+
stopControlCandidateCount: localeLabels.stopControl.length,
|
|
244
|
+
stoppedAssistantCandidateCount: localeLabels.stoppedAssistant.length,
|
|
245
|
+
...localeCoverageSummary.runningState,
|
|
246
|
+
registeredLocaleCount: localeCoverageSummary.registeredLocaleCount,
|
|
247
|
+
nonEnglishLocaleCount: localeCoverageSummary.nonEnglishLocaleCount
|
|
248
|
+
};
|
|
236
249
|
const englishCanonicalPresent = localeLabels.composerTextbox[0] === "Chat with ChatGPT"
|
|
237
250
|
&& localeLabels.sendButton[0] === "Send prompt"
|
|
238
251
|
&& localeLabels.modeLabels.includes("Thinking")
|
|
@@ -248,15 +261,39 @@ function localizationCheck(env) {
|
|
|
248
261
|
missingModeOptionIds,
|
|
249
262
|
toolIds,
|
|
250
263
|
modeOptionIds,
|
|
264
|
+
proAliases,
|
|
265
|
+
supportsProExtendedAlias,
|
|
266
|
+
runningStateLabelCoverage,
|
|
251
267
|
labelCandidateCount,
|
|
252
268
|
pageAvailable: env.page !== undefined,
|
|
253
269
|
runtimeSelectorCoverage: "registry_only_stage_2"
|
|
254
270
|
};
|
|
255
271
|
if (englishCanonicalPresent && requiredKeysMissing.length === 0 && missingToolIds.length === 0 && missingModeOptionIds.length === 0) {
|
|
256
|
-
|
|
272
|
+
const runningStateMessage = runningStateLocalizationSupport === "complete"
|
|
273
|
+
? "running-state labels are present for every registered non-English locale"
|
|
274
|
+
: runningStateLocalizationSupport === "partial"
|
|
275
|
+
? "running-state labels are partially localized"
|
|
276
|
+
: runningStateLocalizationSupport === "english_only"
|
|
277
|
+
? "running-state labels are English-only"
|
|
278
|
+
: "running-state labels are missing";
|
|
279
|
+
return unknown(`The locale registry is loaded; localized runtime selector coverage is registry-only in Stage 2 and ${runningStateMessage}.`, undefined, details);
|
|
257
280
|
}
|
|
258
281
|
return blocked("The locale registry is missing canonical labels required for selector fallback.", ["Update src/dom/locale-labels.ts or src/dom/locale/* with verified visible labels before relying on localized controls."], details, "selector_drift");
|
|
259
282
|
}
|
|
283
|
+
function runningStateSupport() {
|
|
284
|
+
const coverage = localeCoverageSummary.runningState;
|
|
285
|
+
const hasEnglishStop = coverage.stopControlLocaleCount > coverage.nonEnglishStopControlLocaleCount;
|
|
286
|
+
const hasEnglishStopped = coverage.stoppedAssistantLocaleCount > coverage.nonEnglishStoppedAssistantLocaleCount;
|
|
287
|
+
const hasEnglishFallback = hasEnglishStop && hasEnglishStopped;
|
|
288
|
+
const allNonEnglishCovered = localeCoverageSummary.nonEnglishLocaleCount > 0
|
|
289
|
+
&& coverage.nonEnglishStopControlLocaleCount >= localeCoverageSummary.nonEnglishLocaleCount
|
|
290
|
+
&& coverage.nonEnglishStoppedAssistantLocaleCount >= localeCoverageSummary.nonEnglishLocaleCount;
|
|
291
|
+
if (allNonEnglishCovered)
|
|
292
|
+
return "complete";
|
|
293
|
+
if (coverage.nonEnglishStopControlLocaleCount > 0 || coverage.nonEnglishStoppedAssistantLocaleCount > 0)
|
|
294
|
+
return "partial";
|
|
295
|
+
return hasEnglishFallback ? "english_only" : "missing";
|
|
296
|
+
}
|
|
260
297
|
async function reportsCheck(options) {
|
|
261
298
|
const destDir = options?.destDir ?? "reports/runs";
|
|
262
299
|
const includeContent = options?.includeContent === true;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CommandResult, DetectExperienceArgs, DetectExperienceData, OpenExperienceArgs, OpenExperienceData, PageLike, RuntimeEnv } from "../types.js";
|
|
2
|
+
type SurfaceSnapshot = {
|
|
3
|
+
url: string;
|
|
4
|
+
composerLabels: string[];
|
|
5
|
+
mainControls: string[];
|
|
6
|
+
mainText: string;
|
|
7
|
+
selectedSurfaceLabels?: string[];
|
|
8
|
+
};
|
|
9
|
+
export declare function detectExperience(env: RuntimeEnv, args?: DetectExperienceArgs): Promise<CommandResult<DetectExperienceData>>;
|
|
10
|
+
export declare function openExperience(env: RuntimeEnv, args: OpenExperienceArgs): Promise<CommandResult<OpenExperienceData>>;
|
|
11
|
+
export declare function detectExperienceFromSnapshot(snapshot: SurfaceSnapshot): DetectExperienceData;
|
|
12
|
+
export declare function readSurfaceSnapshot(page: PageLike): Promise<SurfaceSnapshot>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { resultError, resultOk } from "../errors.js";
|
|
2
|
+
import { localeLabels } from "../dom/locale-labels.js";
|
|
3
|
+
import { normalizeForLabelMatch, visibleLabelMatches } from "../dom/label-match.js";
|
|
4
|
+
import { contextFromPage } from "./context.js";
|
|
5
|
+
import { ensurePage } from "./session.js";
|
|
6
|
+
const CHATGPT_HOME = "https://chatgpt.com/";
|
|
7
|
+
const EXPERIENCE_CONTROL_DISCOVERY_TIMEOUT_MS = 15_000;
|
|
8
|
+
const EXPERIENCE_POLL_MS = 250;
|
|
9
|
+
export async function detectExperience(env, args = {}) {
|
|
10
|
+
void args;
|
|
11
|
+
const boot = await ensurePage(env);
|
|
12
|
+
if (!boot.ok) {
|
|
13
|
+
return boot;
|
|
14
|
+
}
|
|
15
|
+
const page = env.page;
|
|
16
|
+
try {
|
|
17
|
+
const data = detectExperienceFromSnapshot(await readSurfaceSnapshot(page));
|
|
18
|
+
return resultOk(data, await contextFromPage(page, {
|
|
19
|
+
experience: data.experience,
|
|
20
|
+
selectorProfile: data.selectorProfile
|
|
21
|
+
}), data.experience === "unknown"
|
|
22
|
+
? ["The current ChatGPT surface could not be classified as Chat or Work from scoped composer evidence."]
|
|
23
|
+
: []);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function openExperience(env, args) {
|
|
30
|
+
const boot = await ensurePage(env);
|
|
31
|
+
if (!boot.ok) {
|
|
32
|
+
return boot;
|
|
33
|
+
}
|
|
34
|
+
const page = env.page;
|
|
35
|
+
try {
|
|
36
|
+
const before = detectExperienceFromSnapshot(await readSurfaceSnapshot(page));
|
|
37
|
+
if (before.experience === args.experience) {
|
|
38
|
+
return resultOk({
|
|
39
|
+
experience: args.experience,
|
|
40
|
+
previousExperience: before.experience,
|
|
41
|
+
changed: false,
|
|
42
|
+
selectorProfile: before.selectorProfile
|
|
43
|
+
}, await contextFromPage(page, {
|
|
44
|
+
experience: before.experience,
|
|
45
|
+
selectorProfile: before.selectorProfile
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
const labels = localeLabels.experienceOptions[args.experience];
|
|
49
|
+
const timeoutMs = args.timeoutMs ?? 30000;
|
|
50
|
+
const discoveryAttempts = pollAttempts(Math.min(timeoutMs, EXPERIENCE_CONTROL_DISCOVERY_TIMEOUT_MS), EXPERIENCE_POLL_MS);
|
|
51
|
+
let observed = before;
|
|
52
|
+
let controlClicked = await clickUniqueExperienceControl(page, labels);
|
|
53
|
+
if (!controlClicked && await navigateConversationToSurfaceHome(page, args.timeoutMs)) {
|
|
54
|
+
observed = detectExperienceFromSnapshot(await readSurfaceSnapshot(page));
|
|
55
|
+
if (observed.experience === args.experience) {
|
|
56
|
+
return resultOk({
|
|
57
|
+
experience: args.experience,
|
|
58
|
+
previousExperience: before.experience,
|
|
59
|
+
changed: true,
|
|
60
|
+
selectorProfile: observed.selectorProfile
|
|
61
|
+
}, await contextFromPage(page, {
|
|
62
|
+
experience: observed.experience,
|
|
63
|
+
selectorProfile: observed.selectorProfile
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
controlClicked = await clickUniqueExperienceControl(page, labels);
|
|
67
|
+
}
|
|
68
|
+
// session.bootstrap can verify the composer before the Chat/Work radio has
|
|
69
|
+
// hydrated. Give the scoped surface control a short bounded discovery
|
|
70
|
+
// window instead of reporting selector drift from that transient state.
|
|
71
|
+
for (let attempt = 1; !controlClicked && attempt < discoveryAttempts; attempt += 1) {
|
|
72
|
+
await page.waitForTimeout?.(EXPERIENCE_POLL_MS);
|
|
73
|
+
observed = detectExperienceFromSnapshot(await readSurfaceSnapshot(page));
|
|
74
|
+
if (observed.experience === args.experience) {
|
|
75
|
+
return resultOk({
|
|
76
|
+
experience: args.experience,
|
|
77
|
+
previousExperience: before.experience,
|
|
78
|
+
changed: true,
|
|
79
|
+
selectorProfile: observed.selectorProfile
|
|
80
|
+
}, await contextFromPage(page, {
|
|
81
|
+
experience: observed.experience,
|
|
82
|
+
selectorProfile: observed.selectorProfile
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
controlClicked = await clickUniqueExperienceControl(page, labels);
|
|
86
|
+
}
|
|
87
|
+
if (!controlClicked) {
|
|
88
|
+
return experienceSelectorDrift(page, `No unique visible ChatGPT ${args.experience === "work" ? "Work" : "Chat"} surface control was found.`, observed);
|
|
89
|
+
}
|
|
90
|
+
let after = before;
|
|
91
|
+
for (let attempt = 0; attempt < pollAttempts(timeoutMs, EXPERIENCE_POLL_MS); attempt += 1) {
|
|
92
|
+
await page.waitForTimeout?.(EXPERIENCE_POLL_MS);
|
|
93
|
+
after = detectExperienceFromSnapshot(await readSurfaceSnapshot(page));
|
|
94
|
+
if (after.experience === args.experience) {
|
|
95
|
+
return resultOk({
|
|
96
|
+
experience: args.experience,
|
|
97
|
+
previousExperience: before.experience,
|
|
98
|
+
changed: true,
|
|
99
|
+
selectorProfile: after.selectorProfile
|
|
100
|
+
}, await contextFromPage(page, {
|
|
101
|
+
experience: after.experience,
|
|
102
|
+
selectorProfile: after.selectorProfile
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
status: "blocked",
|
|
109
|
+
warnings: [],
|
|
110
|
+
blocker: {
|
|
111
|
+
kind: "selector_drift",
|
|
112
|
+
code: "experience_postcondition_unverified",
|
|
113
|
+
fieldPath: "experience",
|
|
114
|
+
message: `The ${args.experience} surface control was clicked, but the composer did not verify that ChatGPT switched to ${args.experience}.`,
|
|
115
|
+
candidates: labels.map(label => ({ label })),
|
|
116
|
+
resumable: true
|
|
117
|
+
},
|
|
118
|
+
context: await contextFromPage(page, {
|
|
119
|
+
experience: after.experience,
|
|
120
|
+
selectorProfile: after.selectorProfile
|
|
121
|
+
})
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function pollAttempts(timeoutMs, pollMs) {
|
|
129
|
+
return Math.max(1, Math.ceil(Math.max(0, timeoutMs) / pollMs));
|
|
130
|
+
}
|
|
131
|
+
async function navigateConversationToSurfaceHome(page, timeoutMs) {
|
|
132
|
+
if (page.goto === undefined || page.url === undefined)
|
|
133
|
+
return false;
|
|
134
|
+
const currentUrl = await Promise.resolve(page.url()).catch(() => "");
|
|
135
|
+
if (!/^https:\/\/chatgpt\.com\/c\//i.test(currentUrl))
|
|
136
|
+
return false;
|
|
137
|
+
await page.goto(CHATGPT_HOME, {
|
|
138
|
+
waitUntil: "domcontentloaded",
|
|
139
|
+
timeout: timeoutMs ?? 30000
|
|
140
|
+
});
|
|
141
|
+
await page.waitForTimeout?.(500);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
export function detectExperienceFromSnapshot(snapshot) {
|
|
145
|
+
const evidence = [];
|
|
146
|
+
const composerLabels = snapshot.composerLabels.map(normalizeForLabelMatch);
|
|
147
|
+
const controls = snapshot.mainControls.map(normalizeForLabelMatch);
|
|
148
|
+
const mainText = normalizeForLabelMatch(snapshot.mainText);
|
|
149
|
+
const selectedSurfaceLabels = (snapshot.selectedSurfaceLabels ?? []).map(normalizeForLabelMatch);
|
|
150
|
+
const url = snapshot.url.toLowerCase();
|
|
151
|
+
const selectedWork = matchingLabels(selectedSurfaceLabels, localeLabels.experienceOptions.work);
|
|
152
|
+
const selectedChat = matchingLabels(selectedSurfaceLabels, localeLabels.experienceOptions.chat);
|
|
153
|
+
const workSurfaceSelected = selectedWork.length > 0 && selectedChat.length === 0;
|
|
154
|
+
const chatSurfaceSelected = selectedChat.length > 0 && selectedWork.length === 0;
|
|
155
|
+
if (workSurfaceSelected) {
|
|
156
|
+
evidence.push({ source: "control", label: "Work surface selected" });
|
|
157
|
+
}
|
|
158
|
+
else if (chatSurfaceSelected) {
|
|
159
|
+
evidence.push({ source: "control", label: "Chat surface selected" });
|
|
160
|
+
}
|
|
161
|
+
const workComposer = matchingLabels(composerLabels, localeLabels.workComposerTextbox);
|
|
162
|
+
for (const label of workComposer) {
|
|
163
|
+
evidence.push({ source: "composer", label });
|
|
164
|
+
}
|
|
165
|
+
const chatComposer = matchingLabels(composerLabels, localeLabels.composerTextbox);
|
|
166
|
+
for (const label of chatComposer) {
|
|
167
|
+
evidence.push({ source: "composer", label });
|
|
168
|
+
}
|
|
169
|
+
const workAxisCount = ["model", "effort", "speed"]
|
|
170
|
+
.filter(axis => hasAnyLabel(controls, localeLabels.configurationAxes[axis]))
|
|
171
|
+
.length;
|
|
172
|
+
if (workAxisCount >= 2) {
|
|
173
|
+
evidence.push({ source: "control", label: `Work configuration axes (${workAxisCount}/3)` });
|
|
174
|
+
}
|
|
175
|
+
const workConfigurationOpener = controls.some(label => /\b(?:gpt[\s-]?\d|\d+(?:\.\d+)+|sol|luna|terra)\b/i.test(label)
|
|
176
|
+
&& hasAnyLabel([label], [
|
|
177
|
+
...localeLabels.configurationOptions.light,
|
|
178
|
+
...localeLabels.configurationOptions.medium,
|
|
179
|
+
...localeLabels.configurationOptions.high,
|
|
180
|
+
...localeLabels.configurationOptions.extraHigh,
|
|
181
|
+
...localeLabels.configurationOptions.max,
|
|
182
|
+
...localeLabels.configurationOptions.ultra,
|
|
183
|
+
]));
|
|
184
|
+
if (workConfigurationOpener) {
|
|
185
|
+
evidence.push({ source: "control", label: "Work configuration opener" });
|
|
186
|
+
}
|
|
187
|
+
if (/\/work(?:\/|$|\?)/.test(url)) {
|
|
188
|
+
evidence.push({ source: "url", label: snapshot.url });
|
|
189
|
+
}
|
|
190
|
+
if (containsAny(mainText, ["work on something else", "work on anything"])) {
|
|
191
|
+
evidence.push({ source: "heading", label: "Work composer copy" });
|
|
192
|
+
}
|
|
193
|
+
const workScore = workComposer.length * 4
|
|
194
|
+
+ (workSurfaceSelected ? 10 : 0)
|
|
195
|
+
+ (workAxisCount >= 2 ? 4 : 0)
|
|
196
|
+
// The active Work task drops the Chat/Work radio and keeps the shared
|
|
197
|
+
// "Chat with ChatGPT" textbox name. Its compound model + effort opener is
|
|
198
|
+
// therefore strong enough to disambiguate that continuation surface.
|
|
199
|
+
+ (workConfigurationOpener ? 6 : 0)
|
|
200
|
+
+ (/\/work(?:\/|$|\?)/.test(url) ? 3 : 0)
|
|
201
|
+
+ (containsAny(mainText, ["work on something else", "work on anything"]) ? 2 : 0);
|
|
202
|
+
const chatScore = chatComposer.length * 4
|
|
203
|
+
+ (chatSurfaceSelected ? 10 : 0);
|
|
204
|
+
let experience = "unknown";
|
|
205
|
+
let confidence = "low";
|
|
206
|
+
if (workScore > chatScore && workScore >= 4) {
|
|
207
|
+
experience = "work";
|
|
208
|
+
confidence = workSurfaceSelected || workScore >= 7 ? "high" : "medium";
|
|
209
|
+
}
|
|
210
|
+
else if (chatScore > workScore && chatScore >= 4) {
|
|
211
|
+
experience = "chat";
|
|
212
|
+
confidence = "high";
|
|
213
|
+
}
|
|
214
|
+
const selectorProfile = profileFromSnapshot(snapshot, experience);
|
|
215
|
+
return { experience, selectorProfile, confidence, evidence };
|
|
216
|
+
}
|
|
217
|
+
export async function readSurfaceSnapshot(page) {
|
|
218
|
+
const url = typeof page.url === "function"
|
|
219
|
+
? await Promise.resolve(page.url()).catch(() => "")
|
|
220
|
+
: "";
|
|
221
|
+
if (typeof page.evaluate !== "function") {
|
|
222
|
+
return { url, composerLabels: [], mainControls: [], mainText: "", selectedSurfaceLabels: [] };
|
|
223
|
+
}
|
|
224
|
+
const snapshot = await page.evaluate((surfaceOptionLabels) => {
|
|
225
|
+
const visible = (element) => {
|
|
226
|
+
const html = element;
|
|
227
|
+
const rect = html.getBoundingClientRect?.();
|
|
228
|
+
if (rect !== undefined && (rect.width <= 0 || rect.height <= 0))
|
|
229
|
+
return false;
|
|
230
|
+
let current = element;
|
|
231
|
+
while (current !== null) {
|
|
232
|
+
if (current.hasAttribute?.("inert") || current.getAttribute?.("aria-hidden") === "true") {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const style = typeof window !== "undefined"
|
|
236
|
+
? window.getComputedStyle?.(current)
|
|
237
|
+
: undefined;
|
|
238
|
+
if (style?.display === "none" || style?.visibility === "hidden" || style?.opacity === "0") {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
current = current.parentElement ?? null;
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
};
|
|
245
|
+
const labelFor = (element) => {
|
|
246
|
+
const html = element;
|
|
247
|
+
return element.getAttribute("aria-label")
|
|
248
|
+
?? element.getAttribute("placeholder")
|
|
249
|
+
?? html.innerText
|
|
250
|
+
?? element.textContent
|
|
251
|
+
?? "";
|
|
252
|
+
};
|
|
253
|
+
const normalize = (value) => value.replace(/\s+/g, " ").trim();
|
|
254
|
+
const normalizeComparable = (value) => normalize(value).toLocaleLowerCase();
|
|
255
|
+
const wantedSurfaceLabels = new Set(surfaceOptionLabels.map(normalizeComparable));
|
|
256
|
+
const composerRoots = Array.from(document.querySelectorAll("main form, main [data-testid*='composer' i], main [class*='composer' i]"));
|
|
257
|
+
const composerNodes = composerRoots.flatMap(root => [
|
|
258
|
+
root,
|
|
259
|
+
...Array.from(root.querySelectorAll("textarea, [contenteditable='true'], [role='textbox'], input"))
|
|
260
|
+
]);
|
|
261
|
+
const composerLabels = Array.from(new Set(composerNodes
|
|
262
|
+
.filter(visible)
|
|
263
|
+
.map(labelFor)
|
|
264
|
+
.map(normalize)
|
|
265
|
+
.filter(Boolean)))
|
|
266
|
+
.slice(0, 16);
|
|
267
|
+
const main = document.querySelector("main");
|
|
268
|
+
const overlayRoots = Array.from(document.querySelectorAll("[role='menu'], [role='listbox'], [data-radix-popper-content-wrapper], [data-radix-menu-content]")).filter(visible);
|
|
269
|
+
const controlRoots = Array.from(new Set([...composerRoots, ...overlayRoots]));
|
|
270
|
+
const effectiveControlRoots = controlRoots.length > 0
|
|
271
|
+
? controlRoots
|
|
272
|
+
: main === null ? [] : [main];
|
|
273
|
+
const mainControls = Array.from(new Set(effectiveControlRoots.flatMap(root => Array.from(root.querySelectorAll("button, [role='button'], [role='menuitem'], [role='menuitemradio'], [role='option']")))
|
|
274
|
+
.filter(visible)
|
|
275
|
+
.map(labelFor)
|
|
276
|
+
.map(normalize)
|
|
277
|
+
.filter(Boolean)))
|
|
278
|
+
.slice(0, 120);
|
|
279
|
+
const surfaceTextNodes = main === null ? [] : Array.from(main.querySelectorAll("h1, h2, h3, form, [data-testid*='composer' i], [class*='composer' i]"))
|
|
280
|
+
.filter(visible)
|
|
281
|
+
.slice(0, 32);
|
|
282
|
+
const mainText = normalize(surfaceTextNodes.map(labelFor).join(" ")).slice(0, 2000);
|
|
283
|
+
const selectedSurfaceLabels = Array.from(new Set(Array.from(document.querySelectorAll("[role='radio'][aria-checked='true'], [role='radio'][data-state='checked'], input[type='radio']:checked"))
|
|
284
|
+
.filter(visible)
|
|
285
|
+
.map(labelFor)
|
|
286
|
+
.map(normalize)
|
|
287
|
+
.filter(label => wantedSurfaceLabels.has(normalizeComparable(label)))))
|
|
288
|
+
.slice(0, 4);
|
|
289
|
+
return { composerLabels, mainControls, mainText, selectedSurfaceLabels };
|
|
290
|
+
}, [
|
|
291
|
+
...localeLabels.experienceOptions.chat,
|
|
292
|
+
...localeLabels.experienceOptions.work,
|
|
293
|
+
]).catch(() => ({ composerLabels: [], mainControls: [], mainText: "", selectedSurfaceLabels: [] }));
|
|
294
|
+
return { url, ...snapshot };
|
|
295
|
+
}
|
|
296
|
+
function profileFromSnapshot(snapshot, experience) {
|
|
297
|
+
const controls = snapshot.mainControls.map(normalizeForLabelMatch);
|
|
298
|
+
const mainText = normalizeForLabelMatch(snapshot.mainText);
|
|
299
|
+
if (experience === "work") {
|
|
300
|
+
return hasAnyLabel(controls, localeLabels.configurationAxes.advanced)
|
|
301
|
+
|| containsAny(mainText, localeLabels.configurationAxes.advanced)
|
|
302
|
+
? "work_advanced_v1"
|
|
303
|
+
: "work_basic_v1";
|
|
304
|
+
}
|
|
305
|
+
if (experience !== "chat") {
|
|
306
|
+
return "unknown";
|
|
307
|
+
}
|
|
308
|
+
const simplifiedOptions = [
|
|
309
|
+
...localeLabels.configurationOptions.instant,
|
|
310
|
+
...localeLabels.configurationOptions.medium,
|
|
311
|
+
...localeLabels.configurationOptions.high,
|
|
312
|
+
...localeLabels.configurationOptions.extraHigh,
|
|
313
|
+
...localeLabels.configurationOptions.pro,
|
|
314
|
+
];
|
|
315
|
+
if (hasAnyLabel(controls, simplifiedOptions)) {
|
|
316
|
+
return "chat_simplified_v1";
|
|
317
|
+
}
|
|
318
|
+
const legacyOptions = [
|
|
319
|
+
...localeLabels.modeOptions.latest,
|
|
320
|
+
...localeLabels.modeOptions.thinking,
|
|
321
|
+
...localeLabels.modeOptions.extended,
|
|
322
|
+
];
|
|
323
|
+
return hasAnyLabel(controls, legacyOptions)
|
|
324
|
+
? "chat_legacy_v1"
|
|
325
|
+
: "chat_simplified_v1";
|
|
326
|
+
}
|
|
327
|
+
async function clickUniqueExperienceControl(page, labels) {
|
|
328
|
+
for (const label of labels) {
|
|
329
|
+
for (const role of ["radio", "button", "menuitem", "tab", "link"]) {
|
|
330
|
+
if (await clickIfUnique(page.getByRole?.(role, { name: label, exact: true }))) {
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (typeof page.evaluate !== "function") {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
return page.evaluate((wantedLabels) => {
|
|
339
|
+
const normalize = (value) => value.replace(/\s+/g, " ").trim().toLocaleLowerCase();
|
|
340
|
+
const wanted = new Set(wantedLabels.map(normalize));
|
|
341
|
+
const visible = (element) => {
|
|
342
|
+
const html = element;
|
|
343
|
+
const rect = html.getBoundingClientRect?.();
|
|
344
|
+
if (rect !== undefined && (rect.width <= 0 || rect.height <= 0))
|
|
345
|
+
return false;
|
|
346
|
+
let current = element;
|
|
347
|
+
while (current !== null) {
|
|
348
|
+
if (current.hasAttribute?.("inert") || current.getAttribute?.("aria-hidden") === "true") {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
const style = typeof window !== "undefined"
|
|
352
|
+
? window.getComputedStyle?.(current)
|
|
353
|
+
: undefined;
|
|
354
|
+
if (style?.display === "none" || style?.visibility === "hidden" || style?.opacity === "0") {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
current = current.parentElement ?? null;
|
|
358
|
+
}
|
|
359
|
+
return true;
|
|
360
|
+
};
|
|
361
|
+
const labelFor = (node) => {
|
|
362
|
+
const html = node;
|
|
363
|
+
const inputLabels = "labels" in node
|
|
364
|
+
? Array.from(node.labels ?? []).map(label => label.innerText).join(" ")
|
|
365
|
+
: "";
|
|
366
|
+
return node.getAttribute("aria-label")
|
|
367
|
+
|| inputLabels
|
|
368
|
+
|| html.innerText
|
|
369
|
+
|| node.textContent
|
|
370
|
+
|| "";
|
|
371
|
+
};
|
|
372
|
+
const nodes = Array.from(document.querySelectorAll("[role='radio'], input[type='radio'], header button, header [role='button'], header [role='tab'], main [role='menuitem'], main [role='option']"));
|
|
373
|
+
const matches = nodes.filter(node => visible(node) && wanted.has(normalize(labelFor(node))));
|
|
374
|
+
if (matches.length !== 1)
|
|
375
|
+
return false;
|
|
376
|
+
matches[0].click();
|
|
377
|
+
return true;
|
|
378
|
+
}, labels).catch(() => false);
|
|
379
|
+
}
|
|
380
|
+
async function clickIfUnique(locator) {
|
|
381
|
+
if (locator?.count === undefined || locator.click === undefined) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
if (await locator.count().catch(() => 0) !== 1) {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
await locator.click();
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
function matchingLabels(normalizedHaystack, candidates) {
|
|
391
|
+
const normalizedCandidates = candidates.map(normalizeForLabelMatch);
|
|
392
|
+
return normalizedHaystack
|
|
393
|
+
.filter(label => normalizedCandidates.some(candidate => label === candidate || visibleLabelMatches(label, candidate)))
|
|
394
|
+
.slice(0, 4);
|
|
395
|
+
}
|
|
396
|
+
function hasAnyLabel(normalizedHaystack, candidates) {
|
|
397
|
+
const normalizedCandidates = candidates.map(normalizeForLabelMatch);
|
|
398
|
+
return normalizedHaystack.some(label => normalizedCandidates.some(candidate => label === candidate || visibleLabelMatches(label, candidate)));
|
|
399
|
+
}
|
|
400
|
+
function containsAny(normalizedText, candidates) {
|
|
401
|
+
return candidates.map(normalizeForLabelMatch).some(candidate => normalizedText.includes(candidate));
|
|
402
|
+
}
|
|
403
|
+
async function experienceSelectorDrift(page, message, detected) {
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
status: "unsupported",
|
|
407
|
+
warnings: [],
|
|
408
|
+
blocker: {
|
|
409
|
+
kind: "selector_drift",
|
|
410
|
+
code: "experience_control_not_found",
|
|
411
|
+
fieldPath: "experience",
|
|
412
|
+
message,
|
|
413
|
+
candidates: detected.evidence.map(item => ({ label: `${item.source}: ${item.label}` })),
|
|
414
|
+
resumable: true
|
|
415
|
+
},
|
|
416
|
+
context: await contextFromPage(page, {
|
|
417
|
+
experience: detected.experience,
|
|
418
|
+
selectorProfile: detected.selectorProfile
|
|
419
|
+
})
|
|
420
|
+
};
|
|
421
|
+
}
|