codex-chatgpt-control 0.5.0-alpha.1 → 0.5.1-alpha.2

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.
Files changed (217) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/contracts/v1/fixtures/backend-capabilities.json +1 -0
  3. package/contracts/v1/fixtures/command-descriptors.json +29 -2
  4. package/contracts/v1/fixtures/doctor-bridge-upload.json +2 -2
  5. package/contracts/v1/fixtures/doctor-scenario-preflight.json +26 -4
  6. package/contracts/v1/fixtures/files-attach-indeterminate.json +30 -0
  7. package/contracts/v1/fixtures/help-root.json +1 -1
  8. package/contracts/v1/fixtures/messages-stop-indeterminate.json +31 -0
  9. package/contracts/v1/fixtures/messages-stop-needs-confirmation.json +20 -0
  10. package/contracts/v1/fixtures/messages-stop-noop.json +19 -0
  11. package/contracts/v1/fixtures/messages-stop-success.json +21 -0
  12. package/contracts/v1/fixtures/primitive-bootstrap-blocker.json +3 -3
  13. package/contracts/v1/fixtures/reports-create-redacted.json +3 -3
  14. package/contracts/v1/fixtures/reports-summarize-redacted.json +1 -1
  15. package/contracts/v1/fixtures/run-browser-bridge-blocker.json +12 -12
  16. package/contracts/v1/fixtures/stream-blocked.ndjson +2 -2
  17. package/contracts/v1/manifest.json +25 -0
  18. package/contracts/v1/parity-suite.json +19 -1
  19. package/dist/codex-chatgpt-control-backend.mjs +3654 -603
  20. package/dist/codex-chatgpt-control.bundle.mjs +3627 -546
  21. package/dist/src/backend/client.d.ts +2 -1
  22. package/dist/src/backend/client.js +1 -0
  23. package/dist/src/backend/protocol.d.ts +1 -1
  24. package/dist/src/backend/protocol.js +1 -0
  25. package/dist/src/backend/session.js +2 -0
  26. package/dist/src/browser/attach.d.ts +1 -0
  27. package/dist/src/browser/attach.js +69 -35
  28. package/dist/src/browser/chatgpt-url.d.ts +8 -0
  29. package/dist/src/browser/chatgpt-url.js +28 -0
  30. package/dist/src/browser/downloads.d.ts +1 -1
  31. package/dist/src/browser/downloads.js +13 -4
  32. package/dist/src/browser/page-state.js +211 -4
  33. package/dist/src/client.d.ts +2 -1
  34. package/dist/src/client.js +2 -1
  35. package/dist/src/commands/configuration.js +330 -73
  36. package/dist/src/commands/context.d.ts +5 -1
  37. package/dist/src/commands/context.js +2 -2
  38. package/dist/src/commands/conversation.js +13 -1
  39. package/dist/src/commands/doctor.js +24 -2
  40. package/dist/src/commands/experience.d.ts +1 -0
  41. package/dist/src/commands/experience.js +156 -23
  42. package/dist/src/commands/files.d.ts +1 -0
  43. package/dist/src/commands/files.js +877 -170
  44. package/dist/src/commands/messages.d.ts +2 -1
  45. package/dist/src/commands/messages.js +310 -2
  46. package/dist/src/commands/modes.js +197 -52
  47. package/dist/src/commands/registry.js +8 -0
  48. package/dist/src/commands/sequence.js +3 -1
  49. package/dist/src/commands/session.d.ts +4 -1
  50. package/dist/src/commands/session.js +34 -3
  51. package/dist/src/dom/generation-state.d.ts +6 -1
  52. package/dist/src/dom/generation-state.js +206 -45
  53. package/dist/src/dom/locale/am.d.ts +23 -1
  54. package/dist/src/dom/locale/am.js +23 -1
  55. package/dist/src/dom/locale/ar.d.ts +21 -1
  56. package/dist/src/dom/locale/ar.js +24 -4
  57. package/dist/src/dom/locale/bg.d.ts +19 -1
  58. package/dist/src/dom/locale/bg.js +22 -4
  59. package/dist/src/dom/locale/bn.d.ts +23 -1
  60. package/dist/src/dom/locale/bn.js +23 -1
  61. package/dist/src/dom/locale/bs.d.ts +17 -1
  62. package/dist/src/dom/locale/bs.js +21 -5
  63. package/dist/src/dom/locale/ca.d.ts +20 -1
  64. package/dist/src/dom/locale/ca.js +25 -6
  65. package/dist/src/dom/locale/cs.d.ts +17 -1
  66. package/dist/src/dom/locale/cs.js +17 -1
  67. package/dist/src/dom/locale/da.d.ts +17 -1
  68. package/dist/src/dom/locale/da.js +18 -2
  69. package/dist/src/dom/locale/de.d.ts +16 -1
  70. package/dist/src/dom/locale/de.js +18 -3
  71. package/dist/src/dom/locale/el.d.ts +18 -1
  72. package/dist/src/dom/locale/el.js +21 -4
  73. package/dist/src/dom/locale/en.d.ts +2 -1
  74. package/dist/src/dom/locale/en.js +2 -1
  75. package/dist/src/dom/locale/es-419.d.ts +18 -1
  76. package/dist/src/dom/locale/es-419.js +18 -1
  77. package/dist/src/dom/locale/es-ES.d.ts +18 -1
  78. package/dist/src/dom/locale/es-ES.js +18 -1
  79. package/dist/src/dom/locale/et.d.ts +16 -1
  80. package/dist/src/dom/locale/et.js +16 -1
  81. package/dist/src/dom/locale/fa.d.ts +21 -1
  82. package/dist/src/dom/locale/fa.js +23 -3
  83. package/dist/src/dom/locale/fi.d.ts +18 -1
  84. package/dist/src/dom/locale/fi.js +18 -1
  85. package/dist/src/dom/locale/fr-CA.d.ts +15 -1
  86. package/dist/src/dom/locale/fr-CA.js +15 -1
  87. package/dist/src/dom/locale/fr-FR.d.ts +18 -1
  88. package/dist/src/dom/locale/fr-FR.js +22 -5
  89. package/dist/src/dom/locale/gu.d.ts +23 -1
  90. package/dist/src/dom/locale/gu.js +27 -5
  91. package/dist/src/dom/locale/hi.d.ts +23 -1
  92. package/dist/src/dom/locale/hi.js +25 -3
  93. package/dist/src/dom/locale/hr.d.ts +16 -1
  94. package/dist/src/dom/locale/hr.js +19 -4
  95. package/dist/src/dom/locale/hu.d.ts +17 -1
  96. package/dist/src/dom/locale/hu.js +20 -4
  97. package/dist/src/dom/locale/hy.d.ts +23 -1
  98. package/dist/src/dom/locale/hy.js +26 -4
  99. package/dist/src/dom/locale/id.d.ts +17 -1
  100. package/dist/src/dom/locale/id.js +19 -3
  101. package/dist/src/dom/locale/index.js +1 -0
  102. package/dist/src/dom/locale/is.d.ts +21 -1
  103. package/dist/src/dom/locale/is.js +21 -1
  104. package/dist/src/dom/locale/it.d.ts +15 -1
  105. package/dist/src/dom/locale/it.js +20 -6
  106. package/dist/src/dom/locale/ja.d.ts +19 -1
  107. package/dist/src/dom/locale/ja.js +23 -5
  108. package/dist/src/dom/locale/ka.d.ts +23 -1
  109. package/dist/src/dom/locale/ka.js +23 -1
  110. package/dist/src/dom/locale/kk.d.ts +23 -1
  111. package/dist/src/dom/locale/kk.js +25 -3
  112. package/dist/src/dom/locale/kn.d.ts +22 -1
  113. package/dist/src/dom/locale/kn.js +26 -5
  114. package/dist/src/dom/locale/ko.d.ts +17 -1
  115. package/dist/src/dom/locale/ko.js +17 -1
  116. package/dist/src/dom/locale/lt.d.ts +18 -1
  117. package/dist/src/dom/locale/lt.js +20 -3
  118. package/dist/src/dom/locale/lv.d.ts +18 -1
  119. package/dist/src/dom/locale/lv.js +18 -1
  120. package/dist/src/dom/locale/mk.d.ts +22 -1
  121. package/dist/src/dom/locale/mk.js +26 -5
  122. package/dist/src/dom/locale/ml.d.ts +23 -1
  123. package/dist/src/dom/locale/ml.js +25 -3
  124. package/dist/src/dom/locale/mn.d.ts +23 -1
  125. package/dist/src/dom/locale/mn.js +23 -1
  126. package/dist/src/dom/locale/mr.d.ts +23 -1
  127. package/dist/src/dom/locale/mr.js +25 -3
  128. package/dist/src/dom/locale/ms.d.ts +20 -1
  129. package/dist/src/dom/locale/ms.js +20 -1
  130. package/dist/src/dom/locale/my.d.ts +22 -1
  131. package/dist/src/dom/locale/my.js +24 -3
  132. package/dist/src/dom/locale/nb.d.ts +17 -1
  133. package/dist/src/dom/locale/nb.js +17 -1
  134. package/dist/src/dom/locale/nl.d.ts +20 -2
  135. package/dist/src/dom/locale/nl.js +22 -4
  136. package/dist/src/dom/locale/pa.d.ts +23 -1
  137. package/dist/src/dom/locale/pa.js +27 -5
  138. package/dist/src/dom/locale/pl.d.ts +17 -1
  139. package/dist/src/dom/locale/pl.js +19 -3
  140. package/dist/src/dom/locale/pt-BR.d.ts +18 -1
  141. package/dist/src/dom/locale/pt-BR.js +20 -3
  142. package/dist/src/dom/locale/pt-PT.d.ts +18 -1
  143. package/dist/src/dom/locale/pt-PT.js +22 -5
  144. package/dist/src/dom/locale/ro.d.ts +15 -1
  145. package/dist/src/dom/locale/ro.js +18 -4
  146. package/dist/src/dom/locale/ru.d.ts +23 -1
  147. package/dist/src/dom/locale/ru.js +24 -2
  148. package/dist/src/dom/locale/sk.d.ts +17 -1
  149. package/dist/src/dom/locale/sk.js +19 -3
  150. package/dist/src/dom/locale/sl.d.ts +21 -1
  151. package/dist/src/dom/locale/sl.js +24 -4
  152. package/dist/src/dom/locale/so.d.ts +22 -1
  153. package/dist/src/dom/locale/so.js +24 -3
  154. package/dist/src/dom/locale/sq.d.ts +21 -1
  155. package/dist/src/dom/locale/sq.js +25 -5
  156. package/dist/src/dom/locale/sr.d.ts +24 -1
  157. package/dist/src/dom/locale/sr.js +25 -2
  158. package/dist/src/dom/locale/sv.d.ts +16 -1
  159. package/dist/src/dom/locale/sv.js +16 -1
  160. package/dist/src/dom/locale/sw.d.ts +18 -1
  161. package/dist/src/dom/locale/sw.js +18 -1
  162. package/dist/src/dom/locale/ta.d.ts +23 -1
  163. package/dist/src/dom/locale/ta.js +27 -5
  164. package/dist/src/dom/locale/te.d.ts +23 -1
  165. package/dist/src/dom/locale/te.js +27 -5
  166. package/dist/src/dom/locale/th.d.ts +11 -1
  167. package/dist/src/dom/locale/th.js +11 -1
  168. package/dist/src/dom/locale/tl.d.ts +8 -1
  169. package/dist/src/dom/locale/tl.js +8 -1
  170. package/dist/src/dom/locale/tr.d.ts +21 -1
  171. package/dist/src/dom/locale/tr.js +23 -3
  172. package/dist/src/dom/locale/types.d.ts +1 -1
  173. package/dist/src/dom/locale/uk.d.ts +23 -1
  174. package/dist/src/dom/locale/uk.js +25 -3
  175. package/dist/src/dom/locale/ur.d.ts +22 -1
  176. package/dist/src/dom/locale/ur.js +25 -4
  177. package/dist/src/dom/locale/vi.d.ts +22 -1
  178. package/dist/src/dom/locale/vi.js +25 -4
  179. package/dist/src/dom/locale/zh-HK.d.ts +21 -1
  180. package/dist/src/dom/locale/zh-HK.js +23 -3
  181. package/dist/src/dom/locale/zh-Hans.d.ts +23 -1
  182. package/dist/src/dom/locale/zh-Hans.js +27 -5
  183. package/dist/src/dom/locale/zh-TW.d.ts +23 -1
  184. package/dist/src/dom/locale/zh-TW.js +25 -3
  185. package/dist/src/dom/menus.js +26 -2
  186. package/dist/src/dom/selectors.d.ts +1 -0
  187. package/dist/src/dom/selectors.js +9 -1
  188. package/dist/src/dom/wait-snapshot.d.ts +1 -0
  189. package/dist/src/dom/wait-snapshot.js +45 -22
  190. package/dist/src/errors.d.ts +1 -1
  191. package/dist/src/errors.js +2 -2
  192. package/dist/src/platform/local-paths.d.ts +1 -0
  193. package/dist/src/platform/local-paths.js +7 -3
  194. package/dist/src/safety/risk.d.ts +1 -0
  195. package/dist/src/safety/risk.js +1 -0
  196. package/dist/src/scripts/apply-intelligence-locale-captures.d.ts +10 -1
  197. package/dist/src/scripts/apply-intelligence-locale-captures.js +219 -7
  198. package/dist/src/scripts/capture-intelligence-locales.d.ts +119 -1
  199. package/dist/src/scripts/capture-intelligence-locales.js +653 -232
  200. package/dist/src/scripts/capture-surface-profile.d.ts +3 -1
  201. package/dist/src/scripts/capture-surface-profile.js +75 -25
  202. package/dist/src/scripts/live-smoke/scenarios.d.ts +3 -0
  203. package/dist/src/scripts/live-smoke/scenarios.js +321 -13
  204. package/dist/src/scripts/live-smoke.js +1 -0
  205. package/dist/src/scripts/locale-capture/surface-graph.d.ts +31 -0
  206. package/dist/src/scripts/locale-capture/surface-graph.js +50 -0
  207. package/dist/src/scripts/release-canary-module.d.ts +20 -0
  208. package/dist/src/scripts/release-canary-module.js +86 -0
  209. package/dist/src/types.d.ts +30 -6
  210. package/package.json +8 -2
  211. package/references/2026-07-16-chat-work-surfaces.md +13 -0
  212. package/references/2026-08-08-model-selection-locale-sweep.md +32 -0
  213. package/references/backend-protocol.md +22 -7
  214. package/references/language-coverage.md +24 -13
  215. package/references/localization.md +36 -14
  216. package/references/python-parity.md +9 -3
  217. package/references/troubleshooting.md +26 -4
@@ -1,5 +1,5 @@
1
1
  import { detectExperienceFromSnapshot, readSurfaceSnapshot } from "../commands/experience.js";
2
- import type { BrowserLike, ConfigurationInspectionData, ExistingTabPolicy, SurfaceProfileFixture, SurfaceProfileSupportState } from "../types.js";
2
+ import type { BrowserLike, ChatGPTExperience, ConfigurationInspectionData, ExistingTabPolicy, SurfaceProfileFixture, SurfaceProfileSupportState } from "../types.js";
3
3
  type CaptureRuntime = {
4
4
  agent?: unknown;
5
5
  browser?: BrowserLike;
@@ -16,6 +16,8 @@ type CaptureOptions = {
16
16
  provenance: string;
17
17
  tabId?: string;
18
18
  ifMissing: NonNullable<ExistingTabPolicy["ifMissing"]>;
19
+ experience?: Exclude<ChatGPTExperience, "unknown">;
20
+ restoreExperience: boolean;
19
21
  };
20
22
  export declare function main(argv?: string[], runtime?: CaptureRuntime): Promise<number>;
21
23
  export declare function buildSurfaceProfileDraft(options: Pick<CaptureOptions, "id" | "region" | "accountScope" | "planScope" | "workspaceScope" | "supportState" | "provenance">, locale: string, snapshot: Awaited<ReturnType<typeof readSurfaceSnapshot>>, detected: ReturnType<typeof detectExperienceFromSnapshot>, inspection: ConfigurationInspectionData, observedAt?: string): SurfaceProfileFixture;
@@ -2,7 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { attachChatGPTBrowser } from "../browser/attach.js";
4
4
  import { inspectConfiguration } from "../commands/configuration.js";
5
- import { detectExperienceFromSnapshot, readSurfaceSnapshot } from "../commands/experience.js";
5
+ import { detectExperienceFromSnapshot, openExperience, readSurfaceSnapshot } from "../commands/experience.js";
6
6
  import { BROWSER_BRIDGE_REMEDIATION, BROWSER_BRIDGE_UNAVAILABLE_MESSAGE, toCommandResult } from "../errors.js";
7
7
  const CHATGPT_HOME = "https://chatgpt.com/";
8
8
  const DEFAULT_PROVENANCE = "Read-only sanitized capture from visible ChatGPT controls; review before committing.";
@@ -31,9 +31,12 @@ const USAGE = [
31
31
  " --provenance Non-private provenance note.",
32
32
  " --tab-id Claim an exact already-open ChatGPT tab.",
33
33
  " --if-missing block|open|create. Default: block.",
34
+ " --experience Capture chat|work, switching visibly when needed.",
35
+ " --no-restore-experience Leave the requested surface selected. Default: restore.",
34
36
  "",
35
- "The capture is read-only with respect to ChatGPT configuration. It opens and",
36
- "closes visible menus, writes a sanitized draft locally, and never records",
37
+ "The capture does not mutate model configuration. With --experience it visibly",
38
+ "switches panes and restores the prior pane by default. It writes a sanitized draft",
39
+ "locally and never records",
37
40
  "prompts, responses, sidebar titles, account names, cookies, or network data."
38
41
  ].join("\n");
39
42
  export async function main(argv = process.argv.slice(2), runtime = globalThis) {
@@ -79,27 +82,53 @@ export async function main(argv = process.argv.slice(2), runtime = globalThis) {
79
82
  url: CHATGPT_HOME
80
83
  });
81
84
  env.page = attached.page;
82
- const snapshot = await readSurfaceSnapshot(attached.page);
83
- const detected = detectExperienceFromSnapshot(snapshot);
84
- const inspectionResult = await inspectConfiguration(env, { includeOptions: true });
85
- if (!inspectionResult.ok || inspectionResult.data === undefined) {
86
- console.log(JSON.stringify(inspectionResult, null, 2));
87
- return 1;
85
+ const initial = detectExperienceFromSnapshot(await readSurfaceSnapshot(attached.page));
86
+ const previousExperience = initial.experience === "unknown" ? undefined : initial.experience;
87
+ if (options.experience !== undefined && previousExperience === undefined) {
88
+ throw new Error("Cannot safely switch surfaces because the initial Chat/Work pane could not be identified.");
89
+ }
90
+ try {
91
+ if (options.experience !== undefined) {
92
+ const opened = await openExperience(env, { experience: options.experience, timeoutMs: 60_000 });
93
+ if (!opened.ok || opened.data?.experience !== options.experience) {
94
+ console.log(JSON.stringify(opened, null, 2));
95
+ return 1;
96
+ }
97
+ }
98
+ const snapshot = await readSurfaceSnapshot(attached.page);
99
+ const detected = detectExperienceFromSnapshot(snapshot);
100
+ const inspectionResult = await inspectConfiguration(env, { includeOptions: true });
101
+ if (!inspectionResult.ok || inspectionResult.data === undefined) {
102
+ console.log(JSON.stringify(inspectionResult, null, 2));
103
+ return 1;
104
+ }
105
+ const locale = options.locale ?? await readDocumentLocale(attached.page) ?? "und";
106
+ const profile = buildSurfaceProfileDraft(options, locale, snapshot, detected, inspectionResult.data);
107
+ await mkdir(dirname(options.out), { recursive: true });
108
+ await writeFile(options.out, `${JSON.stringify(profile, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
109
+ console.log(JSON.stringify({
110
+ ok: true,
111
+ status: "ok",
112
+ path: options.out,
113
+ id: profile.id,
114
+ experience: profile.expected.experience,
115
+ selectorProfile: profile.expected.selectorProfile,
116
+ supportState: profile.supportState,
117
+ restoreExperience: options.restoreExperience
118
+ }, null, 2));
119
+ return 0;
120
+ }
121
+ finally {
122
+ if (options.experience !== undefined
123
+ && options.restoreExperience
124
+ && previousExperience !== undefined
125
+ && previousExperience !== options.experience) {
126
+ const restored = await openExperience(env, { experience: previousExperience, timeoutMs: 60_000 });
127
+ if (!restored.ok || restored.data?.experience !== previousExperience) {
128
+ throw new Error(`Captured ${options.experience}, but failed to restore ${previousExperience}.`);
129
+ }
130
+ }
88
131
  }
89
- const locale = options.locale ?? await readDocumentLocale(attached.page) ?? "und";
90
- const profile = buildSurfaceProfileDraft(options, locale, snapshot, detected, inspectionResult.data);
91
- await mkdir(dirname(options.out), { recursive: true });
92
- await writeFile(options.out, `${JSON.stringify(profile, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
93
- console.log(JSON.stringify({
94
- ok: true,
95
- status: "ok",
96
- path: options.out,
97
- id: profile.id,
98
- experience: profile.expected.experience,
99
- selectorProfile: profile.expected.selectorProfile,
100
- supportState: profile.supportState
101
- }, null, 2));
102
- return 0;
103
132
  }
104
133
  catch (error) {
105
134
  console.log(JSON.stringify(toCommandResult(error), null, 2));
@@ -184,6 +213,8 @@ export function parseArgs(argv) {
184
213
  let provenance = DEFAULT_PROVENANCE;
185
214
  let tabId;
186
215
  let ifMissing = "block";
216
+ let experience;
217
+ let restoreExperience = true;
187
218
  for (let index = 0; index < argv.length; index += 1) {
188
219
  const arg = argv[index];
189
220
  if (arg === undefined)
@@ -225,6 +256,15 @@ export function parseArgs(argv) {
225
256
  case "--if-missing":
226
257
  ifMissing = parseIfMissing(requiredValue(argv, ++index, arg));
227
258
  break;
259
+ case "--experience":
260
+ experience = parseExperience(requiredValue(argv, ++index, arg));
261
+ break;
262
+ case "--restore-experience":
263
+ restoreExperience = true;
264
+ break;
265
+ case "--no-restore-experience":
266
+ restoreExperience = false;
267
+ break;
228
268
  default:
229
269
  throw new UsageError(`Unknown argument: ${arg}\n\n${USAGE}`);
230
270
  }
@@ -253,7 +293,9 @@ export function parseArgs(argv) {
253
293
  supportState,
254
294
  provenance,
255
295
  ...(tabId === undefined ? {} : { tabId }),
256
- ifMissing
296
+ ifMissing,
297
+ ...(experience === undefined ? {} : { experience }),
298
+ restoreExperience
257
299
  };
258
300
  }
259
301
  async function readDocumentLocale(page) {
@@ -289,6 +331,11 @@ function parseSupportState(value) {
289
331
  }
290
332
  throw new UsageError(`Invalid --support-state: ${value}`);
291
333
  }
334
+ function parseExperience(value) {
335
+ if (value === "chat" || value === "work")
336
+ return value;
337
+ throw new UsageError("--experience must be chat or work.");
338
+ }
292
339
  function parseIfMissing(value) {
293
340
  if (value === "block" || value === "open" || value === "create")
294
341
  return value;
@@ -299,6 +346,9 @@ function assertNormalizedSlug(value, flag) {
299
346
  throw new UsageError(`${flag} must be a normalized lowercase slug without private names.`);
300
347
  }
301
348
  }
302
- if (import.meta.url === `file://${process.argv[1]}`) {
349
+ if (typeof process !== "undefined"
350
+ && Array.isArray(process.argv)
351
+ && typeof process.argv[1] === "string"
352
+ && import.meta.url === `file://${process.argv[1]}`) {
303
353
  process.exitCode = await main();
304
354
  }
@@ -1,3 +1,6 @@
1
+ import type { AskReadData, CommandResult, ConfigurationInspectionData, ConfigurationSelection } from "../../types.js";
1
2
  import type { LiveSmokeScenario } from "./types.js";
2
3
  export declare const requiredScenarios: LiveSmokeScenario[];
3
4
  export declare const optionalScenarios: LiveSmokeScenario[];
5
+ export declare function generatedFileAskCanProceed(result: CommandResult<AskReadData>): boolean;
6
+ export declare function chatActiveSelection(inspection: ConfigurationInspectionData): ConfigurationSelection;
@@ -1,8 +1,8 @@
1
1
  import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { attachAskRead, attachFiles, askMessage, bootstrap, composeMessage, createChatGPT, copyResponse, downloadLatestAttachment, messageStatus, newThread, openThread, readLatest, runSequence, searchThreads, selectTool, setMode, submitMessage, twoTurnExchange, waitAndRead, waitForMessage } from "../../index.js";
5
- import { readAssistantGenerationState } from "../../dom/generation-state.js";
4
+ import { attachAskRead, attachFiles, askMessage, bootstrap, composeMessage, createChatGPT, copyResponse, downloadLatestAttachment, messageStatus, newThread, openExperience, openThread, readLatest, runSequence, searchThreads, selectTool, setMode, stopGeneration, submitMessage, twoTurnExchange, waitAndRead, waitForMessage } from "../../index.js";
5
+ import { EMPTY_GENERATION_STATE, readAssistantGenerationState } from "../../dom/generation-state.js";
6
6
  import { contextEnvFlag, contextEnvText } from "./harness.js";
7
7
  export const requiredScenarios = [
8
8
  scenario("bootstrap-new-tab", true, () => true, async (context, meta) => {
@@ -122,6 +122,163 @@ export const requiredScenarios = [
122
122
  ? pass(meta, result)
123
123
  : fail(meta, result, { uploadRemediation });
124
124
  }),
125
+ scenario("chat-work-expansion", true, () => true, async (context, meta) => {
126
+ const chatgpt = createChatGPT(clientOptionsFor(context));
127
+ const details = {};
128
+ let booted = false;
129
+ let terminal = syntheticCommand(meta.startedAt);
130
+ let failure;
131
+ try {
132
+ const boot = await chatgpt.session.bootstrap({ preferExistingTab: false, timeoutMs: 60000 });
133
+ terminal = asCommand(boot);
134
+ requireLiveCommand("session.bootstrap", boot);
135
+ booted = true;
136
+ const openedChat = await chatgpt.experience.open({ experience: "chat", timeoutMs: 60000 });
137
+ terminal = asCommand(openedChat);
138
+ requireLiveCommand("experience.open.chat", openedChat, openedChat.ok && openedChat.data?.experience === "chat");
139
+ const chatConfiguration = await chatgpt.configuration.inspect({
140
+ experience: "chat",
141
+ includeOptions: true,
142
+ timeoutMs: 60000
143
+ });
144
+ terminal = asCommand(chatConfiguration);
145
+ requireLiveCommand("configuration.inspect.chat", chatConfiguration, chatConfiguration.ok
146
+ && chatConfiguration.data?.experience === "chat"
147
+ && chatConfiguration.data.verified === true);
148
+ const chatDesired = chatActiveSelection(chatConfiguration.data);
149
+ requireLiveCommand("configuration.inspect.chat.active", chatConfiguration, Object.keys(chatDesired).length > 0);
150
+ const verifiedChatConfiguration = await chatgpt.configuration.apply({
151
+ experience: "chat",
152
+ desired: chatDesired,
153
+ strict: true,
154
+ timeoutMs: 60000
155
+ });
156
+ terminal = asCommand(verifiedChatConfiguration);
157
+ requireLiveCommand("configuration.apply.chat.noop", verifiedChatConfiguration, verifiedChatConfiguration.ok && verifiedChatConfiguration.data?.verified === true);
158
+ const openedWork = await chatgpt.experience.open({ experience: "work", timeoutMs: 60000 });
159
+ terminal = asCommand(openedWork);
160
+ requireLiveCommand("experience.open.work", openedWork, openedWork.ok && openedWork.data?.experience === "work");
161
+ const workConfiguration = await chatgpt.configuration.inspect({
162
+ experience: "work",
163
+ includeOptions: true,
164
+ timeoutMs: 60000
165
+ });
166
+ terminal = asCommand(workConfiguration);
167
+ requireLiveCommand("configuration.inspect.work", workConfiguration, workConfiguration.ok
168
+ && workConfiguration.data?.experience === "work"
169
+ && workConfiguration.data.verified === true
170
+ && hasAxes(workConfiguration.data, ["model", "effort", "speed"]));
171
+ const workDesired = activeSelection(workConfiguration.data, ["model", "effort", "speed"]);
172
+ requireLiveCommand("configuration.inspect.work.active", workConfiguration, hasSelectionAxes(workDesired, ["model", "effort", "speed"]));
173
+ const verifiedWorkConfiguration = await chatgpt.configuration.apply({
174
+ experience: "work",
175
+ desired: workDesired,
176
+ strict: true,
177
+ timeoutMs: 60000
178
+ });
179
+ terminal = asCommand(verifiedWorkConfiguration);
180
+ requireLiveCommand("configuration.apply.work.noop", verifiedWorkConfiguration, verifiedWorkConfiguration.ok && verifiedWorkConfiguration.data?.verified === true);
181
+ const started = await chatgpt.work.start({
182
+ prompt: "Reply with exactly WORK_EXPANSION_START_OK.",
183
+ newTask: true,
184
+ wait: { timeoutMs: 180000, stableMs: 1500, pollMs: 750 },
185
+ read: { format: "normalized_text" },
186
+ timeoutMs: 60000
187
+ });
188
+ terminal = asCommand(started);
189
+ const startedText = started.data?.response?.text ?? started.output_text;
190
+ requireLiveCommand("work.start", started, started.ok && textEquals(startedText, "WORK_EXPANSION_START_OK"));
191
+ const status = await chatgpt.work.status({ includeArtifacts: true, maxPreviewChars: 200 });
192
+ terminal = asCommand(status);
193
+ requireLiveCommand("work.status", status, status.ok && status.data?.experience === "work");
194
+ const waited = await chatgpt.work.wait({
195
+ timeoutMs: 30000,
196
+ stableMs: 1000,
197
+ pollMs: 500,
198
+ responseContent: "metadata"
199
+ });
200
+ terminal = asCommand(waited);
201
+ requireLiveCommand("work.wait", waited, waited.ok && waited.data?.complete === true);
202
+ const read = await chatgpt.work.readLatest({ format: "normalized_text" });
203
+ terminal = asCommand(read);
204
+ requireLiveCommand("work.readLatest", read, read.ok && textEquals(read.data?.text, "WORK_EXPANSION_START_OK"));
205
+ const artifacts = await chatgpt.work.artifacts.listLatest({});
206
+ terminal = asCommand(artifacts);
207
+ requireLiveCommand("work.artifacts.listLatest", artifacts, artifacts.ok);
208
+ const steered = await chatgpt.work.steer({
209
+ prompt: "Reply with exactly WORK_EXPANSION_STEER_OK.",
210
+ wait: { timeoutMs: 180000, stableMs: 1500, pollMs: 750 },
211
+ read: { format: "normalized_text" },
212
+ timeoutMs: 60000
213
+ });
214
+ terminal = asCommand(steered);
215
+ requireLiveCommand("work.steer", steered, steered.ok && textEquals(okText(steered), "WORK_EXPANSION_STEER_OK"));
216
+ const workAgent = chatgpt.agent({
217
+ name: "live-smoke-work-runner",
218
+ defaults: {
219
+ wait: { timeoutMs: 180000, stableMs: 1500, pollMs: 750 },
220
+ read: { format: "normalized_text" }
221
+ }
222
+ });
223
+ const runner = await chatgpt.runner.run(workAgent, {
224
+ input: "Reply with exactly WORK_EXPANSION_RUNNER_OK.",
225
+ thread: { type: "current" },
226
+ experience: "work",
227
+ configuration: workDesired,
228
+ response: { format: "normalized_text" }
229
+ });
230
+ terminal = asCommand(runner);
231
+ requireLiveCommand("runner.run.work", runner, runner.ok && textEquals(runner.output_text, "WORK_EXPANSION_RUNNER_OK"));
232
+ const response = await chatgpt.responses.create({
233
+ input: "Reply with exactly WORK_EXPANSION_RESPONSES_OK.",
234
+ thread: { type: "current" },
235
+ experience: "work",
236
+ configuration: workDesired,
237
+ text: { format: "normalized_text" },
238
+ stream: false
239
+ });
240
+ const responseResult = responseCommand(response);
241
+ terminal = responseResult;
242
+ requireLiveCommand("responses.create.work", responseResult, response.object === "chatgpt.browser.response"
243
+ && response.status === "ok"
244
+ && textEquals(response.output_text, "WORK_EXPANSION_RESPONSES_OK"));
245
+ details.chat = configurationDetails(chatConfiguration.data);
246
+ details.work = configurationDetails(workConfiguration.data);
247
+ details.workLifecycle = {
248
+ start: true,
249
+ status: true,
250
+ wait: true,
251
+ read: true,
252
+ steer: true,
253
+ artifacts: true,
254
+ runner: true,
255
+ responses: true
256
+ };
257
+ }
258
+ catch (error) {
259
+ if (error instanceof LiveSmokeCommandFailure) {
260
+ failure = error;
261
+ }
262
+ else {
263
+ throw error;
264
+ }
265
+ }
266
+ finally {
267
+ if (booted) {
268
+ const restored = await chatgpt.experience.open({ experience: "chat", timeoutMs: 60000 });
269
+ terminal = asCommand(restored);
270
+ if (!restored.ok || restored.data?.experience !== "chat") {
271
+ failure = new LiveSmokeCommandFailure("experience.restore.chat", asCommand(restored));
272
+ }
273
+ else {
274
+ details.restoredExperience = "chat";
275
+ }
276
+ }
277
+ }
278
+ return failure === undefined
279
+ ? pass(meta, terminal, details)
280
+ : fail(meta, failure.command, { ...details, failedStage: failure.stage });
281
+ }),
125
282
  scenario("redacted-run-report", true, () => true, async (context, meta) => {
126
283
  const chatgpt = createChatGPT(clientOptionsFor(context));
127
284
  const command = {
@@ -448,6 +605,84 @@ export const requiredScenarios = [
448
605
  })
449
606
  ];
450
607
  export const optionalScenarios = [
608
+ scenario("configuration-mutate-restore", false, context => contextEnvFlag(context, "CHATGPT_E2E_CONFIGURATION_MUTATION"), async (context, meta) => {
609
+ const chatgpt = createChatGPT(clientOptionsFor(context));
610
+ const details = {};
611
+ let booted = false;
612
+ let restoreNeeded = false;
613
+ let originalEffort;
614
+ let terminal = syntheticCommand(meta.startedAt);
615
+ let failure;
616
+ try {
617
+ const boot = await chatgpt.session.bootstrap({ preferExistingTab: false, timeoutMs: 60000 });
618
+ terminal = asCommand(boot);
619
+ requireLiveCommand("session.bootstrap", boot);
620
+ booted = true;
621
+ const opened = await chatgpt.experience.open({ experience: "work", timeoutMs: 60000 });
622
+ terminal = asCommand(opened);
623
+ requireLiveCommand("experience.open.work", opened, opened.ok && opened.data?.experience === "work");
624
+ const inspected = await chatgpt.configuration.inspect({
625
+ experience: "work",
626
+ includeOptions: true,
627
+ timeoutMs: 60000
628
+ });
629
+ terminal = asCommand(inspected);
630
+ requireLiveCommand("configuration.inspect.work", inspected, inspected.ok && inspected.data?.verified === true);
631
+ originalEffort = inspected.data?.active.effort;
632
+ const alternative = inspected.data?.options.effort?.find(option => !option.selected && option.disabled !== true)?.label;
633
+ requireLiveCommand("configuration.inspect.work.alternative", inspected, originalEffort !== undefined && alternative !== undefined);
634
+ restoreNeeded = true;
635
+ const changed = await chatgpt.configuration.apply({
636
+ experience: "work",
637
+ desired: { effort: alternative },
638
+ strict: true,
639
+ timeoutMs: 60000
640
+ });
641
+ terminal = asCommand(changed);
642
+ requireLiveCommand("configuration.apply.work.mutate", changed, changed.ok && changed.data?.verified === true);
643
+ details.axis = "effort";
644
+ details.changedFrom = originalEffort;
645
+ details.changedTo = alternative;
646
+ }
647
+ catch (error) {
648
+ if (error instanceof LiveSmokeCommandFailure) {
649
+ failure = error;
650
+ }
651
+ else {
652
+ throw error;
653
+ }
654
+ }
655
+ finally {
656
+ if (booted && restoreNeeded && originalEffort !== undefined) {
657
+ const restoredConfiguration = await chatgpt.configuration.apply({
658
+ experience: "work",
659
+ desired: { effort: originalEffort },
660
+ strict: true,
661
+ timeoutMs: 60000
662
+ });
663
+ terminal = asCommand(restoredConfiguration);
664
+ if (!restoredConfiguration.ok || restoredConfiguration.data?.verified !== true) {
665
+ failure = new LiveSmokeCommandFailure("configuration.restore.work", asCommand(restoredConfiguration));
666
+ }
667
+ else {
668
+ details.configurationRestored = true;
669
+ }
670
+ }
671
+ if (booted) {
672
+ const restoredExperience = await chatgpt.experience.open({ experience: "chat", timeoutMs: 60000 });
673
+ terminal = asCommand(restoredExperience);
674
+ if (!restoredExperience.ok || restoredExperience.data?.experience !== "chat") {
675
+ failure = new LiveSmokeCommandFailure("experience.restore.chat", asCommand(restoredExperience));
676
+ }
677
+ else {
678
+ details.restoredExperience = "chat";
679
+ }
680
+ }
681
+ }
682
+ return failure === undefined
683
+ ? pass(meta, terminal, details)
684
+ : fail(meta, failure.command, { ...details, failedStage: failure.stage });
685
+ }),
451
686
  scenario("long-response-partial-short-timeout", false, context => contextEnvFlag(context, "CHATGPT_E2E_LONG_PARTIAL"), async (context, meta) => {
452
687
  const env = await bootNewThread(context, meta);
453
688
  if ("status" in env)
@@ -533,17 +768,35 @@ export const optionalScenarios = [
533
768
  const env = await bootNewThread(context, meta);
534
769
  if ("status" in env)
535
770
  return env;
771
+ const chat = await openExperience(env, { experience: "chat", timeoutMs: 60000 });
772
+ if (!chat.ok || chat.data?.experience !== "chat") {
773
+ return fail(meta, chat, { failedStage: "experience.open.chat" });
774
+ }
536
775
  const asked = await askMessage(env, {
537
776
  text: "Create a tiny CSV file named chatgpt-live-smoke.csv containing one row with columns name,value and values smoke,1. Provide it as a downloadable file.",
538
777
  wait: { timeoutMs: 180000, stableMs: 3000 },
539
778
  read: true
540
779
  });
541
- if (!asked.ok)
780
+ if (!generatedFileAskCanProceed(asked))
542
781
  return fail(meta, asked);
543
- const result = await downloadLatestAttachment({ destDir: context.reportDir, timeoutMs: 120000 }, env);
544
- const path = typeof result.data === "object" && result.data !== null ? result.data.path : undefined;
782
+ const result = await downloadLatestAttachment({
783
+ destDir: context.reportDir,
784
+ filenamePattern: "^chatgpt-live-smoke\\.csv$",
785
+ timeoutMs: 120000
786
+ }, env);
787
+ const download = typeof result.data === "object" && result.data !== null
788
+ ? result.data
789
+ : undefined;
790
+ const path = download?.path;
545
791
  const bytes = path === undefined ? 0 : (await stat(path).catch(() => undefined))?.size ?? 0;
546
- return result.ok && bytes > 0 ? pass(meta, result, { path, bytes }) : fail(meta, result, { path, bytes });
792
+ const content = path === undefined ? "" : await readFile(path, "utf8").catch(() => "");
793
+ const rows = content.replace(/^\uFEFF/, "").trim().split(/\r?\n/).map(row => row.trim());
794
+ const exactFile = download?.suggestedFilename === "chatgpt-live-smoke.csv";
795
+ const exactContent = rows[0] === "name,value" && rows[1] === "smoke,1" && rows.length === 2;
796
+ const details = { path, bytes, suggestedFilename: download?.suggestedFilename, exactFile, exactContent, askStatus: asked.status };
797
+ return result.ok && bytes > 0 && exactFile && exactContent
798
+ ? pass(meta, result, details)
799
+ : fail(meta, result, details);
547
800
  }),
548
801
  scenario("set-mode-visible", false, context => contextEnvText(context, "CHATGPT_E2E_MODE_LABEL") !== undefined, async (context, meta) => {
549
802
  const label = requireInput(contextEnvText(context, "CHATGPT_E2E_MODE_LABEL"), "CHATGPT_E2E_MODE_LABEL");
@@ -606,6 +859,10 @@ export const optionalScenarios = [
606
859
  : fail(meta, result, { events });
607
860
  })
608
861
  ];
862
+ export function generatedFileAskCanProceed(result) {
863
+ return result.ok
864
+ || (result.status === "partial" && result.data?.generationActive !== true);
865
+ }
609
866
  function scenario(name, required, enabled, run) {
610
867
  return {
611
868
  name,
@@ -722,6 +979,61 @@ function responseCommand(response) {
722
979
  context: { timestamp: new Date(response.created_at * 1000).toISOString() }
723
980
  };
724
981
  }
982
+ class LiveSmokeCommandFailure extends Error {
983
+ stage;
984
+ command;
985
+ constructor(stage, command) {
986
+ super(`Live smoke command failed at ${stage}`);
987
+ this.stage = stage;
988
+ this.command = command;
989
+ this.name = "LiveSmokeCommandFailure";
990
+ }
991
+ }
992
+ function requireLiveCommand(stage, command, condition = command.ok) {
993
+ if (!condition) {
994
+ throw new LiveSmokeCommandFailure(stage, asCommand(command));
995
+ }
996
+ }
997
+ function asCommand(command) {
998
+ return command;
999
+ }
1000
+ function syntheticCommand(timestamp) {
1001
+ return {
1002
+ ok: true,
1003
+ status: "ok",
1004
+ warnings: [],
1005
+ context: { timestamp }
1006
+ };
1007
+ }
1008
+ function activeSelection(inspection, axes) {
1009
+ const selection = {};
1010
+ for (const axis of axes) {
1011
+ const value = inspection.active[axis];
1012
+ if (value !== undefined) {
1013
+ selection[axis] = value;
1014
+ }
1015
+ }
1016
+ return selection;
1017
+ }
1018
+ export function chatActiveSelection(inspection) {
1019
+ return activeSelection(inspection, ["intelligence", "model", "modelVersion", "effort"]);
1020
+ }
1021
+ function hasAxes(inspection, axes) {
1022
+ return axes.every(axis => inspection.availableAxes.includes(axis));
1023
+ }
1024
+ function hasSelectionAxes(selection, axes) {
1025
+ return axes.every(axis => selection[axis] !== undefined);
1026
+ }
1027
+ function configurationDetails(inspection) {
1028
+ return {
1029
+ experience: inspection.experience,
1030
+ selectorProfile: inspection.selectorProfile,
1031
+ verified: inspection.verified,
1032
+ availableAxes: inspection.availableAxes,
1033
+ active: inspection.active,
1034
+ optionCounts: Object.fromEntries(Object.entries(inspection.options).map(([axis, options]) => [axis, options?.length ?? 0]))
1035
+ };
1036
+ }
725
1037
  function partialCaptureDetails(result, output) {
726
1038
  return {
727
1039
  resultStatus: result.status,
@@ -734,21 +1046,17 @@ async function waitForGenerationSignal(env, timeoutMs) {
734
1046
  const started = Date.now();
735
1047
  while (Date.now() - started < timeoutMs) {
736
1048
  if (env.page !== undefined) {
737
- const state = await readAssistantGenerationState(env.page).catch(() => ({ active: false, stopped: false, signals: [] }));
1049
+ const state = await readAssistantGenerationState(env.page).catch(() => EMPTY_GENERATION_STATE);
738
1050
  if (state.active || state.stopped) {
739
1051
  return state;
740
1052
  }
741
1053
  }
742
1054
  await env.page?.waitForTimeout?.(500);
743
1055
  }
744
- return { active: false, stopped: false, signals: [] };
1056
+ return EMPTY_GENERATION_STATE;
745
1057
  }
746
1058
  async function stopGenerationIfVisible(env) {
747
- const stop = env.page?.getByRole?.("button", { name: /Stop answering|Stop generating|Stop streaming|Cancel/i });
748
- const clicked = stop?.click?.({ timeoutMs: 5000 });
749
- if (clicked !== undefined) {
750
- await clicked.catch(() => undefined);
751
- }
1059
+ await stopGeneration(env, { confirmStop: true, timeoutMs: 5_000 }).catch(() => undefined);
752
1060
  }
753
1061
  function hashPreview(text) {
754
1062
  let hash = 0;
@@ -39,6 +39,7 @@ else {
39
39
  context.knownConversationId = knownConversationId;
40
40
  }
41
41
  context.env = {
42
+ CHATGPT_E2E_CONFIGURATION_MUTATION: envText("CHATGPT_E2E_CONFIGURATION_MUTATION"),
42
43
  CHATGPT_E2E_CREATE_IMAGE: envText("CHATGPT_E2E_CREATE_IMAGE"),
43
44
  CHATGPT_E2E_DEEP_RESEARCH: envText("CHATGPT_E2E_DEEP_RESEARCH"),
44
45
  CHATGPT_E2E_DOWNLOAD: envText("CHATGPT_E2E_DOWNLOAD"),
@@ -0,0 +1,31 @@
1
+ export type RawSurfaceOption = {
2
+ label: string;
3
+ checked: boolean;
4
+ };
5
+ export type RawConfigurationRow = {
6
+ label: string;
7
+ axisLabel: string;
8
+ valueLabel?: string;
9
+ options: Array<{
10
+ label: string;
11
+ checked: boolean;
12
+ }>;
13
+ };
14
+ export type RawWorkConfigurationRow = RawConfigurationRow;
15
+ export type CapturedChatConfigurationRow = RawConfigurationRow & {
16
+ axis: "model" | "effort";
17
+ };
18
+ export type CapturedWorkConfigurationRow = RawConfigurationRow & {
19
+ axis: "model" | "effort" | "speed";
20
+ };
21
+ export declare function assignChatSelectedSurfaceOptions(options: readonly RawSurfaceOption[]): {
22
+ chatLabel: string;
23
+ workLabel: string;
24
+ };
25
+ export declare function assignOrderedSurfaceOptions(options: readonly RawSurfaceOption[]): {
26
+ chatLabel: string;
27
+ workLabel: string;
28
+ selected: "chat" | "work";
29
+ };
30
+ export declare function assignOrderedWorkConfigurationRows(rows: readonly RawWorkConfigurationRow[]): CapturedWorkConfigurationRow[];
31
+ export declare function assignOrderedChatConfigurationRows(rows: readonly RawConfigurationRow[]): CapturedChatConfigurationRow[];
@@ -0,0 +1,50 @@
1
+ export function assignChatSelectedSurfaceOptions(options) {
2
+ const unique = dedupeByLabel(options.filter(option => option.label.trim().length > 0));
3
+ const selected = unique.filter(option => option.checked);
4
+ const unselected = unique.filter(option => !option.checked);
5
+ if (unique.length !== 2 || selected.length !== 1 || unselected.length !== 1) {
6
+ throw new Error(`Expected one selected Chat radio and one unselected Work radio; observed ${unique.length} options.`);
7
+ }
8
+ return {
9
+ chatLabel: selected[0].label,
10
+ workLabel: unselected[0].label
11
+ };
12
+ }
13
+ export function assignOrderedSurfaceOptions(options) {
14
+ const unique = dedupeByLabel(options.filter(option => option.label.trim().length > 0));
15
+ const selected = unique.filter(option => option.checked);
16
+ if (unique.length !== 2 || selected.length !== 1) {
17
+ throw new Error(`Expected ordered Chat and Work radios with one selected option; observed ${unique.length} options.`);
18
+ }
19
+ return {
20
+ chatLabel: unique[0].label,
21
+ workLabel: unique[1].label,
22
+ selected: unique[0].checked ? "chat" : "work"
23
+ };
24
+ }
25
+ export function assignOrderedWorkConfigurationRows(rows) {
26
+ if (rows.length !== 3) {
27
+ throw new Error(`Expected three ordered Work configuration rows; observed ${rows.length}.`);
28
+ }
29
+ const axes = ["model", "effort", "speed"];
30
+ return rows.map((row, index) => ({ ...row, axis: axes[index] }));
31
+ }
32
+ export function assignOrderedChatConfigurationRows(rows) {
33
+ if (rows.length !== 2) {
34
+ throw new Error(`Expected two ordered Chat configuration rows; observed ${rows.length}.`);
35
+ }
36
+ const axes = ["model", "effort"];
37
+ return rows.map((row, index) => ({ ...row, axis: axes[index] }));
38
+ }
39
+ function dedupeByLabel(options) {
40
+ const seen = new Set();
41
+ const result = [];
42
+ for (const option of options) {
43
+ const key = option.label.replace(/\s+/g, " ").trim().toLocaleLowerCase();
44
+ if (seen.has(key))
45
+ continue;
46
+ seen.add(key);
47
+ result.push({ label: option.label.replace(/\s+/g, " ").trim(), checked: option.checked });
48
+ }
49
+ return result;
50
+ }