codex-chatgpt-control 0.1.0-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.
Files changed (179) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +54 -0
  4. package/contracts/v1/fixtures/backend-capabilities.json +54 -0
  5. package/contracts/v1/fixtures/backend-error-event-missing-stream-input.json +10 -0
  6. package/contracts/v1/fixtures/backend-error-missing-run-input.json +10 -0
  7. package/contracts/v1/fixtures/backend-runner-plan-request.json +12 -0
  8. package/contracts/v1/fixtures/backend-version.json +10 -0
  9. package/contracts/v1/fixtures/command-descriptors.json +803 -0
  10. package/contracts/v1/fixtures/describe-runner-run.json +31 -0
  11. package/contracts/v1/fixtures/doctor-bridge-upload.json +34 -0
  12. package/contracts/v1/fixtures/help-root.json +6 -0
  13. package/contracts/v1/fixtures/named-plan-two-turn.json +39 -0
  14. package/contracts/v1/fixtures/output-json-parse-success.json +115 -0
  15. package/contracts/v1/fixtures/primitive-bootstrap-blocker.json +43 -0
  16. package/contracts/v1/fixtures/report-redaction-default.json +16 -0
  17. package/contracts/v1/fixtures/reports-create-redacted.json +16 -0
  18. package/contracts/v1/fixtures/reports-summarize-redacted.json +26 -0
  19. package/contracts/v1/fixtures/responses-basic-success.json +27 -0
  20. package/contracts/v1/fixtures/responses-hidden-instructions-unsupported.json +22 -0
  21. package/contracts/v1/fixtures/responses-unknown-field-unsupported.json +22 -0
  22. package/contracts/v1/fixtures/responses-unsupported-previous-response-id.json +22 -0
  23. package/contracts/v1/fixtures/responses-unsupported-temperature.json +22 -0
  24. package/contracts/v1/fixtures/run-basic-success.json +36 -0
  25. package/contracts/v1/fixtures/run-browser-bridge-blocker.json +180 -0
  26. package/contracts/v1/fixtures/run-file-attach-success.json +63 -0
  27. package/contracts/v1/fixtures/run-report-redacted.json +52 -0
  28. package/contracts/v1/fixtures/run-selector-drift-blocker.json +60 -0
  29. package/contracts/v1/fixtures/run-timeout-partial.json +81 -0
  30. package/contracts/v1/fixtures/run-two-turn-success.json +83 -0
  31. package/contracts/v1/fixtures/run-upload-permission-blocker.json +56 -0
  32. package/contracts/v1/fixtures/runner-budget-blocker.json +106 -0
  33. package/contracts/v1/fixtures/runner-full-agent-config.json +50 -0
  34. package/contracts/v1/fixtures/runner-input-items-and-files-plan.json +53 -0
  35. package/contracts/v1/fixtures/runner-metadata-only-plan.json +31 -0
  36. package/contracts/v1/fixtures/runner-visible-prefix-plan.json +28 -0
  37. package/contracts/v1/fixtures/runner-visible-setup-plan.json +37 -0
  38. package/contracts/v1/fixtures/stream-basic.ndjson +3 -0
  39. package/contracts/v1/fixtures/stream-blocked.ndjson +2 -0
  40. package/contracts/v1/fixtures/stream-submitted-completed.ndjson +3 -0
  41. package/contracts/v1/fixtures/workflow-ask-success.json +67 -0
  42. package/contracts/v1/manifest.json +210 -0
  43. package/contracts/v1/parity-suite.json +635 -0
  44. package/contracts/v1/schemas/agent.schema.json +52 -0
  45. package/contracts/v1/schemas/backend-event.schema.json +74 -0
  46. package/contracts/v1/schemas/backend-request.schema.json +59 -0
  47. package/contracts/v1/schemas/backend-response.schema.json +42 -0
  48. package/contracts/v1/schemas/capabilities.schema.json +33 -0
  49. package/contracts/v1/schemas/command-descriptor.schema.json +31 -0
  50. package/contracts/v1/schemas/command-result.schema.json +99 -0
  51. package/contracts/v1/schemas/manifest.schema.json +69 -0
  52. package/contracts/v1/schemas/response.schema.json +51 -0
  53. package/contracts/v1/schemas/run-result.schema.json +138 -0
  54. package/contracts/v1/schemas/sequence-plan.schema.json +44 -0
  55. package/contracts/v1/schemas/stream-event.schema.json +25 -0
  56. package/dist/codex-chatgpt-control-backend.mjs +5284 -0
  57. package/dist/codex-chatgpt-control.bundle.mjs +6013 -0
  58. package/dist/src/backend/client.d.ts +129 -0
  59. package/dist/src/backend/client.js +415 -0
  60. package/dist/src/backend/protocol.d.ts +74 -0
  61. package/dist/src/backend/protocol.js +128 -0
  62. package/dist/src/backend/session.d.ts +11 -0
  63. package/dist/src/backend/session.js +223 -0
  64. package/dist/src/backend/stdio-server.d.ts +9 -0
  65. package/dist/src/backend/stdio-server.js +88 -0
  66. package/dist/src/browser/attach.d.ts +8 -0
  67. package/dist/src/browser/attach.js +456 -0
  68. package/dist/src/browser/clipboard.d.ts +2 -0
  69. package/dist/src/browser/clipboard.js +26 -0
  70. package/dist/src/browser/downloads.d.ts +7 -0
  71. package/dist/src/browser/downloads.js +29 -0
  72. package/dist/src/browser/page-state.d.ts +17 -0
  73. package/dist/src/browser/page-state.js +66 -0
  74. package/dist/src/client.d.ts +167 -0
  75. package/dist/src/client.js +666 -0
  76. package/dist/src/commands/confirmations.d.ts +9 -0
  77. package/dist/src/commands/confirmations.js +33 -0
  78. package/dist/src/commands/context.d.ts +2 -0
  79. package/dist/src/commands/context.js +32 -0
  80. package/dist/src/commands/doctor.d.ts +16 -0
  81. package/dist/src/commands/doctor.js +120 -0
  82. package/dist/src/commands/files.d.ts +4 -0
  83. package/dist/src/commands/files.js +314 -0
  84. package/dist/src/commands/helpers.d.ts +16 -0
  85. package/dist/src/commands/helpers.js +128 -0
  86. package/dist/src/commands/messages.d.ts +16 -0
  87. package/dist/src/commands/messages.js +538 -0
  88. package/dist/src/commands/modes.d.ts +9 -0
  89. package/dist/src/commands/modes.js +323 -0
  90. package/dist/src/commands/output.d.ts +5 -0
  91. package/dist/src/commands/output.js +31 -0
  92. package/dist/src/commands/registry.d.ts +17 -0
  93. package/dist/src/commands/registry.js +275 -0
  94. package/dist/src/commands/reports.d.ts +14 -0
  95. package/dist/src/commands/reports.js +47 -0
  96. package/dist/src/commands/response-actions.d.ts +2 -0
  97. package/dist/src/commands/response-actions.js +141 -0
  98. package/dist/src/commands/sequence.d.ts +9 -0
  99. package/dist/src/commands/sequence.js +228 -0
  100. package/dist/src/commands/session.d.ts +2 -0
  101. package/dist/src/commands/session.js +25 -0
  102. package/dist/src/commands/threads.d.ts +6 -0
  103. package/dist/src/commands/threads.js +300 -0
  104. package/dist/src/dom/fixtures.d.ts +1 -0
  105. package/dist/src/dom/fixtures.js +8 -0
  106. package/dist/src/dom/menus.d.ts +8 -0
  107. package/dist/src/dom/menus.js +40 -0
  108. package/dist/src/dom/message-format.d.ts +30 -0
  109. package/dist/src/dom/message-format.js +593 -0
  110. package/dist/src/dom/messages.d.ts +40 -0
  111. package/dist/src/dom/messages.js +127 -0
  112. package/dist/src/dom/selectors.d.ts +20 -0
  113. package/dist/src/dom/selectors.js +72 -0
  114. package/dist/src/dom/visible-text.d.ts +5 -0
  115. package/dist/src/dom/visible-text.js +27 -0
  116. package/dist/src/errors.d.ts +32 -0
  117. package/dist/src/errors.js +127 -0
  118. package/dist/src/index.d.ts +34 -0
  119. package/dist/src/index.js +34 -0
  120. package/dist/src/logger.d.ts +15 -0
  121. package/dist/src/logger.js +25 -0
  122. package/dist/src/runner/agent.d.ts +2 -0
  123. package/dist/src/runner/agent.js +17 -0
  124. package/dist/src/runner/index.d.ts +7 -0
  125. package/dist/src/runner/index.js +7 -0
  126. package/dist/src/runner/interruptions.d.ts +3 -0
  127. package/dist/src/runner/interruptions.js +65 -0
  128. package/dist/src/runner/responses.d.ts +30 -0
  129. package/dist/src/runner/responses.js +171 -0
  130. package/dist/src/runner/result.d.ts +3 -0
  131. package/dist/src/runner/result.js +128 -0
  132. package/dist/src/runner/resume.d.ts +11 -0
  133. package/dist/src/runner/resume.js +26 -0
  134. package/dist/src/runner/stream.d.ts +13 -0
  135. package/dist/src/runner/stream.js +66 -0
  136. package/dist/src/runner/types.d.ts +264 -0
  137. package/dist/src/runner/types.js +1 -0
  138. package/dist/src/safety/blockers.d.ts +7 -0
  139. package/dist/src/safety/blockers.js +60 -0
  140. package/dist/src/safety/redaction.d.ts +2 -0
  141. package/dist/src/safety/redaction.js +18 -0
  142. package/dist/src/safety/report-redaction.d.ts +8 -0
  143. package/dist/src/safety/report-redaction.js +74 -0
  144. package/dist/src/safety/risk.d.ts +26 -0
  145. package/dist/src/safety/risk.js +28 -0
  146. package/dist/src/scripts/backend-server.d.ts +2 -0
  147. package/dist/src/scripts/backend-server.js +7 -0
  148. package/dist/src/scripts/check-parity-suite.d.ts +1 -0
  149. package/dist/src/scripts/check-parity-suite.js +10 -0
  150. package/dist/src/scripts/continue-thread.d.ts +27 -0
  151. package/dist/src/scripts/continue-thread.js +388 -0
  152. package/dist/src/scripts/live-smoke/harness.d.ts +11 -0
  153. package/dist/src/scripts/live-smoke/harness.js +151 -0
  154. package/dist/src/scripts/live-smoke/scenarios.d.ts +3 -0
  155. package/dist/src/scripts/live-smoke/scenarios.js +648 -0
  156. package/dist/src/scripts/live-smoke/types.d.ts +53 -0
  157. package/dist/src/scripts/live-smoke/types.js +1 -0
  158. package/dist/src/scripts/live-smoke-module.d.ts +3 -0
  159. package/dist/src/scripts/live-smoke-module.js +2 -0
  160. package/dist/src/scripts/live-smoke.d.ts +1 -0
  161. package/dist/src/scripts/live-smoke.js +54 -0
  162. package/dist/src/scripts/smoke-attach-file.d.ts +1 -0
  163. package/dist/src/scripts/smoke-attach-file.js +39 -0
  164. package/dist/src/scripts/smoke-hi.d.ts +1 -0
  165. package/dist/src/scripts/smoke-hi.js +25 -0
  166. package/dist/src/scripts/smoke-search-open-read.d.ts +1 -0
  167. package/dist/src/scripts/smoke-search-open-read.js +41 -0
  168. package/dist/src/testing/parity-suite.d.ts +12 -0
  169. package/dist/src/testing/parity-suite.js +213 -0
  170. package/dist/src/types.d.ts +495 -0
  171. package/dist/src/types.js +1 -0
  172. package/package.json +81 -0
  173. package/references/agents-runner.md +32 -0
  174. package/references/backend-protocol.md +238 -0
  175. package/references/python-parity.md +168 -0
  176. package/references/responses-adapter.md +28 -0
  177. package/references/safety.md +14 -0
  178. package/references/streaming.md +15 -0
  179. package/references/troubleshooting.md +91 -0
@@ -0,0 +1,648 @@
1
+ import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { attachAskRead, attachFiles, askMessage, bootstrap, composeMessage, createChatGPT, copyResponse, downloadLatestAttachment, newThread, openThread, readLatest, runSequence, searchThreads, selectTool, setMode, submitMessage, twoTurnExchange, waitAndRead, waitForMessage } from "../../index.js";
5
+ import { contextEnvFlag, contextEnvText } from "./harness.js";
6
+ export const requiredScenarios = [
7
+ scenario("bootstrap-new-tab", true, () => true, async (context, meta) => {
8
+ const env = envFor(context);
9
+ const result = await bootstrap(env, { preferExistingTab: false, timeoutMs: 60000 });
10
+ return result.ok && result.context.url?.includes("chatgpt.com") === true
11
+ ? pass(meta, result)
12
+ : fail(meta, result);
13
+ }),
14
+ scenario("bootstrap-reuse-tab", true, () => true, async (context, meta) => {
15
+ const env = envFor(context);
16
+ const created = await bootstrap(env, { preferExistingTab: false, timeoutMs: 60000 });
17
+ if (!created.ok)
18
+ return fail(meta, created);
19
+ const reused = await bootstrap(env, { preferExistingTab: true, timeoutMs: 60000 });
20
+ return reused.ok && reused.context.tabId === created.context.tabId
21
+ ? pass(meta, reused, { createdTabId: created.context.tabId, reusedTabId: reused.context.tabId })
22
+ : fail(meta, reused, { createdTabId: created.context.tabId, reusedTabId: reused.context.tabId });
23
+ }),
24
+ scenario("new-ask-read", true, () => true, async (context, meta) => {
25
+ const env = await bootNewThread(context, meta);
26
+ if ("status" in env)
27
+ return env;
28
+ const result = await askMessage(env, {
29
+ text: "reply with the word hi",
30
+ wait: { timeoutMs: 120000, stableMs: 2000 },
31
+ read: true
32
+ });
33
+ return textEquals(okText(result), "hi") ? pass(meta, result) : fail(meta, result);
34
+ }),
35
+ scenario("compose-submit-wait-read", true, () => true, async (context, meta) => {
36
+ const env = await bootNewThread(context, meta);
37
+ if ("status" in env)
38
+ return env;
39
+ const text = "reply with the word hi";
40
+ const composed = await composeMessage(env, { text });
41
+ if (!composed.ok)
42
+ return fail(meta, composed);
43
+ const submitted = await submitMessage(env, { text, timeoutMs: 30000 });
44
+ if (!submitted.ok)
45
+ return fail(meta, submitted);
46
+ const waited = await waitForMessage(env, { timeoutMs: 120000, stableMs: 2000 });
47
+ if (!waited.ok)
48
+ return fail(meta, waited);
49
+ const read = await readLatest(env, { role: "assistant", format: "normalized_text" });
50
+ return textEquals(read.data?.text, "hi") ? pass(meta, read) : fail(meta, read);
51
+ }),
52
+ scenario("wait-and-read", true, () => true, async (context, meta) => {
53
+ const env = await bootNewThread(context, meta);
54
+ if ("status" in env)
55
+ return env;
56
+ const asked = await askMessage(env, { text: "reply with the word hi", wait: false, read: false });
57
+ if (!asked.ok)
58
+ return fail(meta, asked);
59
+ const result = await waitAndRead(env, { timeoutMs: 120000, stableMs: 2000, role: "assistant", format: "normalized_text" });
60
+ return textEquals(okText(result), "hi") ? pass(meta, result) : fail(meta, result);
61
+ }),
62
+ scenario("format-fidelity-markdown-default", true, () => true, async (context, meta) => {
63
+ const env = await bootNewThread(context, meta);
64
+ if ("status" in env)
65
+ return env;
66
+ const prompt = [
67
+ "Respond with exactly this Markdown structure and no extra prose:",
68
+ "",
69
+ "## Format Fidelity",
70
+ "",
71
+ "- Markdown default",
72
+ "- Structure preserved",
73
+ "",
74
+ "```ts",
75
+ "const format = \"markdown\";",
76
+ "```",
77
+ "",
78
+ "| Format | Purpose |",
79
+ "| --- | --- |",
80
+ "| markdown | reports |"
81
+ ].join("\n");
82
+ const asked = await askMessage(env, {
83
+ text: prompt,
84
+ wait: { timeoutMs: 120000, stableMs: 2000 },
85
+ read: false
86
+ });
87
+ if (!asked.ok)
88
+ return fail(meta, asked);
89
+ const result = await readLatest(env, { role: "assistant" });
90
+ const markdown = result.data?.markdown ?? result.data?.text ?? "";
91
+ if (!(result.ok
92
+ && result.data?.format === "markdown"
93
+ && markdown.includes("## Format Fidelity")
94
+ && markdown.includes("- Markdown default")
95
+ && markdown.includes("```")
96
+ && markdown.includes("| Format | Purpose |"))) {
97
+ return fail(meta, result, { markdownPreview: markdown.slice(0, 500), format: result.data?.format });
98
+ }
99
+ const copied = await copyResponse(env, { prefer: "clipboard", format: "markdown" });
100
+ const copiedMarkdown = copied.data?.markdown ?? copied.data?.text ?? "";
101
+ const copySourceOk = copied.data?.source === "clipboard"
102
+ || (copied.data?.source === "dom" && copied.warnings.some(warning => warning.includes("clipboard") || warning.includes("DOM-derived")));
103
+ return copied.ok
104
+ && copySourceOk
105
+ && copiedMarkdown.includes("## Format Fidelity")
106
+ && copiedMarkdown.includes("- Markdown default")
107
+ && copiedMarkdown.includes("```")
108
+ && copiedMarkdown.includes("| Format | Purpose |")
109
+ ? pass(meta, copied, { readSource: result.data?.source, copySource: copied.data?.source })
110
+ : fail(meta, copied, { copiedPreview: copiedMarkdown.slice(0, 500), copySource: copied.data?.source });
111
+ }),
112
+ scenario("sdk-doctor", true, () => true, async (context, meta) => {
113
+ const chatgpt = createChatGPT(clientOptionsFor(context));
114
+ const result = await chatgpt.doctor({ check: ["bridge", "login", "upload"] });
115
+ const uploadRemediation = result.data?.checks.upload?.remediation?.join(" ") ?? "";
116
+ return result.ok
117
+ && result.data?.checks.bridge?.status === "ok"
118
+ && result.data?.checks.login?.status !== "blocked"
119
+ && uploadRemediation.includes("Codex Settings > Computer Use > Chrome")
120
+ && uploadRemediation.includes("Allow access to file URLs")
121
+ ? pass(meta, result)
122
+ : fail(meta, result, { uploadRemediation });
123
+ }),
124
+ scenario("redacted-run-report", true, () => true, async (context, meta) => {
125
+ const chatgpt = createChatGPT(clientOptionsFor(context));
126
+ const command = {
127
+ ok: true,
128
+ status: "ok",
129
+ data: {
130
+ responseText: "private@example.com /example/user/private token_12345678901234567890123456789012"
131
+ },
132
+ warnings: [],
133
+ context: { timestamp: meta.startedAt, url: "https://chatgpt.com/c/redacted-smoke" }
134
+ };
135
+ const result = await chatgpt.createReport(command, { destDir: context.reportDir, basename: "redacted-run-report" });
136
+ const path = result.data?.path;
137
+ const body = path === undefined ? "" : await readFile(path, "utf8").catch(() => "");
138
+ return result.ok
139
+ && body.includes("[redacted:")
140
+ && !body.includes("private@example.com")
141
+ && !body.includes("/example/user/private")
142
+ ? pass(meta, result, { path })
143
+ : fail(meta, result, { path, bodyPreview: body.slice(0, 500) });
144
+ }),
145
+ scenario("runner-new-ask-read", true, () => true, async (context, meta) => {
146
+ const chatgpt = createChatGPT(clientOptionsFor(context));
147
+ const agent = chatgpt.agent({
148
+ name: "live-smoke-runner",
149
+ defaults: {
150
+ wait: { timeoutMs: 120000, stableMs: 2000 },
151
+ read: { format: "normalized_text" }
152
+ }
153
+ });
154
+ const result = await chatgpt.runner.run(agent, {
155
+ input: "reply with the word hi",
156
+ thread: { type: "new" },
157
+ response: { format: "normalized_text" }
158
+ });
159
+ return textEquals(result.output_text, "hi") ? pass(meta, result) : fail(meta, result);
160
+ }),
161
+ scenario("runner-attach-ask-read", true, () => true, async (context, meta) => {
162
+ const chatgpt = createChatGPT(clientOptionsFor(context));
163
+ const agent = chatgpt.agent({
164
+ name: "live-smoke-runner-attach",
165
+ defaults: {
166
+ wait: { timeoutMs: 180000, stableMs: 2000 },
167
+ read: { format: "normalized_text" }
168
+ }
169
+ });
170
+ const file = await tempFile("chatgpt-live-smoke-runner-attach.txt", "Runner attachment fixture.\n");
171
+ const result = await chatgpt.runner.run(agent, {
172
+ input: "Reply with the attached filename only.",
173
+ thread: { type: "new" },
174
+ attachments: [{ path: file }],
175
+ response: { format: "normalized_text" }
176
+ });
177
+ return includesUploadedFilename(result.output_text, "chatgpt-live-smoke-runner-attach.txt") ? pass(meta, result) : fail(meta, result);
178
+ }),
179
+ scenario("runner-search-open-ask-read", true, () => true, async (context, meta) => {
180
+ const query = requireInput(context.knownThreadQuery, "CHATGPT_SMOKE_QUERY");
181
+ const chatgpt = createChatGPT(clientOptionsFor(context));
182
+ const agent = chatgpt.agent({
183
+ name: "live-smoke-runner-search",
184
+ defaults: {
185
+ wait: { timeoutMs: 120000, stableMs: 2000 },
186
+ read: { format: "normalized_text" }
187
+ }
188
+ });
189
+ const result = await chatgpt.runner.run(agent, {
190
+ input: "reply with the word hi",
191
+ thread: { type: "search", query, select: "first" },
192
+ response: { format: "normalized_text" }
193
+ });
194
+ return textEquals(result.output_text, "hi") ? pass(meta, result) : fail(meta, result);
195
+ }),
196
+ scenario("runner-two-turn", true, () => true, async (context, meta) => {
197
+ const chatgpt = createChatGPT(clientOptionsFor(context));
198
+ const agent = chatgpt.agent({
199
+ name: "live-smoke-runner-two-turn",
200
+ defaults: {
201
+ wait: { timeoutMs: 120000, stableMs: 2000 },
202
+ read: { format: "normalized_text" }
203
+ }
204
+ });
205
+ const first = await chatgpt.runner.run(agent, {
206
+ input: "Reply with exactly alpha.",
207
+ thread: { type: "new" },
208
+ response: { format: "normalized_text" }
209
+ });
210
+ if (!textEquals(first.output_text, "alpha"))
211
+ return fail(meta, first, { first: first.output_text });
212
+ const second = await chatgpt.runner.run(agent, {
213
+ input: "Reply with exactly beta.",
214
+ thread: { type: "current" },
215
+ response: { format: "normalized_text" }
216
+ });
217
+ return textEquals(second.output_text, "beta")
218
+ ? pass(meta, second, { first: first.output_text, second: second.output_text })
219
+ : fail(meta, second, { first: first.output_text, second: second.output_text });
220
+ }),
221
+ scenario("runner-report-redacted", true, () => true, async (context, meta) => {
222
+ const chatgpt = createChatGPT(clientOptionsFor(context));
223
+ const agent = chatgpt.agent({
224
+ name: "live-smoke-runner-report",
225
+ defaults: {
226
+ wait: { timeoutMs: 120000, stableMs: 2000 },
227
+ read: { format: "normalized_text" }
228
+ }
229
+ });
230
+ const secret = "runnerreportsecret";
231
+ const result = await chatgpt.runner.run(agent, {
232
+ input: `reply with the word ${secret}`,
233
+ thread: { type: "new" },
234
+ response: { format: "normalized_text" },
235
+ report: { enabled: true, destDir: context.reportDir, basename: "runner-report-redacted", includeContent: false }
236
+ });
237
+ const path = result.data?.reportPath ?? result.reportPath;
238
+ const body = path === undefined ? "" : await readFile(path, "utf8").catch(() => "");
239
+ return result.ok
240
+ && path !== undefined
241
+ && body.includes("[redacted:")
242
+ && !body.includes(secret)
243
+ ? pass(meta, result, { path })
244
+ : fail(meta, result, { path, bodyPreview: body.slice(0, 500), output: result.output_text });
245
+ }),
246
+ scenario("runner-mode-unavailable", true, () => true, async (context, meta) => {
247
+ const chatgpt = createChatGPT(clientOptionsFor(context));
248
+ const agent = chatgpt.agent({ name: "live-smoke-runner-mode" });
249
+ const result = await chatgpt.runner.run(agent, {
250
+ input: "reply with hi",
251
+ thread: { type: "new" },
252
+ mode: { model: "definitely-not-a-visible-chatgpt-mode", timeoutMs: 30000 },
253
+ response: { format: "normalized_text" }
254
+ });
255
+ const interruption = result.interruptions[0];
256
+ return !result.ok
257
+ && interruption?.type === "selector_drift"
258
+ && (interruption.blocker?.candidates?.length ?? 0) > 0
259
+ ? pass(meta, result, { candidates: interruption.blocker?.candidates })
260
+ : fail(meta, result, { interruptions: result.interruptions });
261
+ }),
262
+ scenario("responses-create-basic", true, () => true, async (context, meta) => {
263
+ const chatgpt = createChatGPT(clientOptionsFor(context));
264
+ const response = await chatgpt.responses.create({
265
+ input: "reply with the word hi",
266
+ thread: { type: "new" },
267
+ text: { format: "normalized_text" },
268
+ stream: false
269
+ });
270
+ const command = responseCommand(response);
271
+ return response.object === "chatgpt.browser.response" && textEquals(response.output_text, "hi")
272
+ ? pass(meta, command)
273
+ : fail(meta, command);
274
+ }),
275
+ scenario("responses-unsupported-temperature", true, () => true, async (context, meta) => {
276
+ const chatgpt = createChatGPT(clientOptionsFor(context));
277
+ const response = await chatgpt.responses.create({
278
+ input: "hi",
279
+ temperature: 0.2
280
+ });
281
+ const command = responseCommand(response);
282
+ const unsupported = response.browser_control.unsupported ?? [];
283
+ return response.status === "unsupported" && unsupported.some(field => field.path === "temperature")
284
+ ? pass(meta, command)
285
+ : fail(meta, command);
286
+ }),
287
+ scenario("responses-unsupported-previous-response-id", true, () => true, async (context, meta) => {
288
+ const chatgpt = createChatGPT(clientOptionsFor(context));
289
+ const response = await chatgpt.responses.create({
290
+ input: "hi",
291
+ previous_response_id: "resp_123"
292
+ });
293
+ const command = responseCommand(response);
294
+ const unsupported = response.browser_control.unsupported ?? [];
295
+ return response.status === "unsupported"
296
+ && unsupported.some(field => field.path === "previous_response_id" && field.alternative?.includes("thread") === true)
297
+ ? pass(meta, command)
298
+ : fail(meta, command);
299
+ }),
300
+ scenario("two-turn-exchange", true, () => true, async (context, meta) => {
301
+ const env = await bootNewThread(context, meta);
302
+ if ("status" in env)
303
+ return env;
304
+ const result = await twoTurnExchange({
305
+ thread: {},
306
+ text: "Reply with exactly alpha.",
307
+ followupText: "Reply with exactly beta."
308
+ }, env);
309
+ const first = stepPreviewText(result.steps, "ask1");
310
+ const second = okText(result);
311
+ return result.ok && includesText(first, "alpha") && includesText(second, "beta")
312
+ ? pass(meta, result, { firstPreview: first, secondPreview: second })
313
+ : fail(meta, result, { firstPreview: first, secondPreview: second });
314
+ }),
315
+ scenario("search-open-read", true, () => true, async (context, meta) => {
316
+ const query = requireInput(context.knownThreadQuery, "CHATGPT_SMOKE_QUERY");
317
+ const env = await boot(context, meta);
318
+ if ("status" in env)
319
+ return env;
320
+ const search = await searchThreads(env, { query, limit: 5 });
321
+ if (!search.ok || search.data?.results[0] === undefined)
322
+ return fail(meta, search);
323
+ const opened = await openThread(env, { fromStep: "find", select: "first" }, new Map([["find", search]]));
324
+ if (!opened.ok)
325
+ return fail(meta, opened);
326
+ const read = await readLatest(env, { role: "assistant", format: "normalized_text" });
327
+ return read.ok && (read.data?.text.trim().length ?? 0) > 0 ? pass(meta, read) : fail(meta, read);
328
+ }),
329
+ scenario("open-by-url", true, () => true, async (context, meta) => {
330
+ const url = requireInput(context.knownThreadUrl, "CHATGPT_SMOKE_THREAD_URL");
331
+ const env = await boot(context, meta);
332
+ if ("status" in env)
333
+ return env;
334
+ const opened = await openThread(env, { url, timeoutMs: 60000 });
335
+ if (!opened.ok)
336
+ return fail(meta, opened);
337
+ const read = await readLatest(env, { role: "assistant", format: "normalized_text" });
338
+ return read.ok && opened.context.url?.includes(url) === true && (read.data?.text.trim().length ?? 0) > 0
339
+ ? pass(meta, read, { openedUrl: opened.context.url })
340
+ : fail(meta, read, { openedUrl: opened.context.url });
341
+ }),
342
+ scenario("open-by-conversation-id", true, () => true, async (context, meta) => {
343
+ const conversationId = requireInput(context.knownConversationId, "CHATGPT_SMOKE_CONVERSATION_ID");
344
+ const env = await boot(context, meta);
345
+ if ("status" in env)
346
+ return env;
347
+ const opened = await openThread(env, { conversationId, timeoutMs: 60000 });
348
+ if (!opened.ok)
349
+ return fail(meta, opened);
350
+ const read = await readLatest(env, { role: "assistant", format: "normalized_text" });
351
+ return read.ok && opened.context.url?.includes(conversationId) === true && (read.data?.text.trim().length ?? 0) > 0
352
+ ? pass(meta, read, { openedUrl: opened.context.url })
353
+ : fail(meta, read, { openedUrl: opened.context.url });
354
+ }),
355
+ scenario("sequence-variable-open", true, () => true, async (context, meta) => {
356
+ const query = requireInput(context.knownThreadQuery, "CHATGPT_SMOKE_QUERY");
357
+ const env = envFor(context);
358
+ const plan = {
359
+ name: "live-smoke-sequence-variable-open",
360
+ steps: [
361
+ { id: "bootstrap", command: "session.bootstrap", args: { preferExistingTab: false, timeoutMs: 60000 } },
362
+ { id: "find", command: "threads.search", args: { query, limit: 5 } },
363
+ { id: "open", command: "threads.open", args: { conversationId: "${find.data.results[0].conversationId}", timeoutMs: 60000 } },
364
+ { id: "read", command: "messages.readLatest", args: { role: "assistant", format: "normalized_text" } }
365
+ ]
366
+ };
367
+ const result = await runSequence(plan, env);
368
+ return result.ok && includesStep(result.steps, "read") && okText(result).trim().length > 0
369
+ ? pass(meta, result)
370
+ : fail(meta, result);
371
+ }),
372
+ scenario("copy-latest", true, () => true, async (context, meta) => {
373
+ const url = requireInput(context.knownThreadUrl, "CHATGPT_SMOKE_THREAD_URL");
374
+ const env = await boot(context, meta);
375
+ if ("status" in env)
376
+ return env;
377
+ const opened = await openThread(env, { url, timeoutMs: 60000 });
378
+ if (!opened.ok)
379
+ return fail(meta, opened);
380
+ const result = await copyResponse(env, { which: "latest", timeoutMs: 5000 });
381
+ return result.ok && (result.data?.text.trim().length ?? 0) > 0
382
+ ? pass(meta, result, { source: result.data?.source })
383
+ : fail(meta, result);
384
+ }),
385
+ scenario("attach-one-file", true, () => true, async (context, meta) => {
386
+ const env = await bootNewThread(context, meta);
387
+ if ("status" in env)
388
+ return env;
389
+ const file = await tempFile("chatgpt-live-smoke-single.txt", "Single file fixture.\n");
390
+ const attached = await attachFiles(env, { paths: [file], timeoutMs: 180000 });
391
+ if (!attached.ok)
392
+ return fail(meta, attached);
393
+ const result = await askMessage(env, {
394
+ text: "Reply with the attached filename only.",
395
+ wait: { timeoutMs: 180000, stableMs: 2000 },
396
+ read: true
397
+ });
398
+ return includesUploadedFilename(okText(result), "chatgpt-live-smoke-single.txt") ? pass(meta, result) : fail(meta, result);
399
+ }),
400
+ scenario("attach-two-files", true, () => true, async (context, meta) => {
401
+ const env = await bootNewThread(context, meta);
402
+ if ("status" in env)
403
+ return env;
404
+ const first = await tempFile("chatgpt-live-smoke-a.txt", "File A fixture.\n");
405
+ const second = await tempFile("chatgpt-live-smoke-b.txt", "File B fixture.\n");
406
+ const attached = await attachFiles(env, { paths: [first, second], timeoutMs: 180000 });
407
+ if (!attached.ok)
408
+ return fail(meta, attached);
409
+ const result = await askMessage(env, {
410
+ text: "Reply with both attached filenames only.",
411
+ wait: { timeoutMs: 180000, stableMs: 2000 },
412
+ read: true
413
+ });
414
+ const text = okText(result);
415
+ return includesUploadedFilename(text, "chatgpt-live-smoke-a.txt") && includesUploadedFilename(text, "chatgpt-live-smoke-b.txt")
416
+ ? pass(meta, result)
417
+ : fail(meta, result);
418
+ }),
419
+ scenario("attach-ask-read", true, () => true, async (context, meta) => {
420
+ const env = envFor(context);
421
+ const file = await tempFile("chatgpt-live-smoke-helper.txt", "Helper fixture.\n");
422
+ const result = await attachAskRead({
423
+ thread: {},
424
+ files: [file],
425
+ text: "Reply with the attached filename only.",
426
+ wait: { timeoutMs: 180000, stableMs: 2000 },
427
+ read: true
428
+ }, env);
429
+ return includesUploadedFilename(okText(result), "chatgpt-live-smoke-helper.txt") ? pass(meta, result) : fail(meta, result);
430
+ }),
431
+ scenario("wait-timeout", true, () => true, async (context, meta) => {
432
+ const env = await bootNewThread(context, meta);
433
+ if ("status" in env)
434
+ return env;
435
+ const result = await waitForMessage(env, { timeoutMs: 1000, stableMs: 500, pollMs: 250 });
436
+ return !result.ok && result.status === "timeout" ? pass(meta, result) : fail(meta, result);
437
+ }),
438
+ scenario("missing-thread", true, () => true, async (context, meta) => {
439
+ const env = await boot(context, meta);
440
+ if ("status" in env)
441
+ return env;
442
+ const title = `chatgpt-live-smoke-missing-${Date.now()}`;
443
+ const result = await openThread(env, { title, timeoutMs: 30000 });
444
+ return !result.ok && result.status === "not_found"
445
+ ? pass(meta, result, { title })
446
+ : fail(meta, result, { title });
447
+ })
448
+ ];
449
+ export const optionalScenarios = [
450
+ scenario("download-generated-file", false, context => contextEnvFlag(context, "CHATGPT_E2E_DOWNLOAD"), async (context, meta) => {
451
+ const env = await bootNewThread(context, meta);
452
+ if ("status" in env)
453
+ return env;
454
+ const asked = await askMessage(env, {
455
+ 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.",
456
+ wait: { timeoutMs: 180000, stableMs: 3000 },
457
+ read: true
458
+ });
459
+ if (!asked.ok)
460
+ return fail(meta, asked);
461
+ const result = await downloadLatestAttachment({ destDir: context.reportDir, timeoutMs: 120000 }, env);
462
+ const path = typeof result.data === "object" && result.data !== null ? result.data.path : undefined;
463
+ const bytes = path === undefined ? 0 : (await stat(path).catch(() => undefined))?.size ?? 0;
464
+ return result.ok && bytes > 0 ? pass(meta, result, { path, bytes }) : fail(meta, result, { path, bytes });
465
+ }),
466
+ scenario("set-mode-visible", false, context => contextEnvText(context, "CHATGPT_E2E_MODE_LABEL") !== undefined, async (context, meta) => {
467
+ const label = requireInput(contextEnvText(context, "CHATGPT_E2E_MODE_LABEL"), "CHATGPT_E2E_MODE_LABEL");
468
+ const env = await boot(context, meta);
469
+ if ("status" in env)
470
+ return env;
471
+ const result = await setMode(env, { model: label, timeoutMs: 30000 });
472
+ return result.ok || result.status === "unsupported" ? pass(meta, result) : fail(meta, result);
473
+ }),
474
+ scenario("select-web-search", false, context => contextEnvFlag(context, "CHATGPT_E2E_WEB_SEARCH"), async (context, meta) => {
475
+ const env = await bootNewThread(context, meta);
476
+ if ("status" in env)
477
+ return env;
478
+ const selected = await selectTool(env, { tool: "web_search", timeoutMs: 30000 });
479
+ if (!selected.ok && selected.status !== "unsupported")
480
+ return fail(meta, selected);
481
+ const asked = selected.ok
482
+ ? await askMessage(env, { text: "reply with the word hi", wait: { timeoutMs: 120000, stableMs: 2000 }, read: true })
483
+ : selected;
484
+ return selected.status === "unsupported" || textEquals(okText(asked), "hi") ? pass(meta, asked) : fail(meta, asked);
485
+ }),
486
+ scenario("select-deep-research", false, context => contextEnvFlag(context, "CHATGPT_E2E_DEEP_RESEARCH"), async (context, meta) => selectToolScenario(context, meta, "deep_research")),
487
+ scenario("select-create-image", false, context => contextEnvFlag(context, "CHATGPT_E2E_CREATE_IMAGE"), async (context, meta) => selectToolScenario(context, meta, "create_image")),
488
+ scenario("login-required-manual", false, context => contextEnvFlag(context, "CHATGPT_E2E_LOGIN_PROFILE"), async (context, meta) => {
489
+ const env = envFor(context);
490
+ const result = await bootstrap(env, { preferExistingTab: false, timeoutMs: 60000 });
491
+ return !result.ok && result.blocker?.kind === "login_required" ? pass(meta, result) : fail(meta, result);
492
+ }),
493
+ scenario("upload-permission-manual", false, context => contextEnvFlag(context, "CHATGPT_E2E_UPLOAD_PERMISSION_MANUAL"), async (context, meta) => {
494
+ const env = await bootNewThread(context, meta);
495
+ if ("status" in env)
496
+ return env;
497
+ const file = await tempFile("chatgpt-live-smoke-upload-blocker.txt", "Upload blocker fixture.\n");
498
+ const result = await attachFiles(env, { paths: [file], timeoutMs: 60000 });
499
+ return !result.ok && result.blocker?.kind === "permission" && /Uploads|Allow access to file URLs/i.test(result.blocker.message)
500
+ ? pass(meta, result)
501
+ : fail(meta, result);
502
+ }),
503
+ scenario("stream-milestones", false, context => contextEnvFlag(context, "CHATGPT_E2E_STREAM"), async (context, meta) => {
504
+ const chatgpt = createChatGPT(clientOptionsFor(context));
505
+ const agent = chatgpt.agent({
506
+ name: "live-smoke-stream",
507
+ defaults: {
508
+ wait: { timeoutMs: 120000, stableMs: 2000 },
509
+ read: { format: "normalized_text" }
510
+ }
511
+ });
512
+ const stream = chatgpt.runner.run(agent, {
513
+ input: "reply with the word hi",
514
+ thread: { type: "new" },
515
+ response: { format: "normalized_text" }
516
+ }, { stream: true });
517
+ const events = [];
518
+ for await (const event of stream) {
519
+ events.push(event.name);
520
+ }
521
+ const result = await stream.completed;
522
+ return textEquals(result.output_text, "hi") && events.includes("message_completed")
523
+ ? pass(meta, result, { events })
524
+ : fail(meta, result, { events });
525
+ })
526
+ ];
527
+ function scenario(name, required, enabled, run) {
528
+ return {
529
+ name,
530
+ required,
531
+ enabled,
532
+ run: context => {
533
+ const startedAt = new Date().toISOString();
534
+ return run(context, { name, required, startedAt, startedMs: Date.now() });
535
+ }
536
+ };
537
+ }
538
+ async function selectToolScenario(context, meta, tool) {
539
+ const env = await boot(context, meta);
540
+ if ("status" in env)
541
+ return env;
542
+ const result = await selectTool(env, { tool, timeoutMs: 30000 });
543
+ return result.ok || result.status === "unsupported" ? pass(meta, result) : fail(meta, result);
544
+ }
545
+ async function boot(context, meta) {
546
+ const env = envFor(context);
547
+ const booted = await bootstrap(env, { preferExistingTab: false, timeoutMs: 60000 });
548
+ return booted.ok ? env : fail(meta, booted);
549
+ }
550
+ async function bootNewThread(context, meta) {
551
+ const env = await boot(context, meta);
552
+ if ("status" in env)
553
+ return env;
554
+ const created = await newThread(env);
555
+ return created.ok ? env : fail(meta, created);
556
+ }
557
+ function envFor(context) {
558
+ const env = { agent: context.agent };
559
+ if (context.browser !== undefined) {
560
+ env.browser = context.browser;
561
+ }
562
+ return env;
563
+ }
564
+ function clientOptionsFor(context) {
565
+ return envFor(context);
566
+ }
567
+ function pass(meta, command, details) {
568
+ return finish(meta, "pass", command, details);
569
+ }
570
+ function fail(meta, command, details) {
571
+ return finish(meta, "fail", command, details);
572
+ }
573
+ function finish(meta, status, command, details) {
574
+ const result = {
575
+ name: meta.name,
576
+ status,
577
+ required: meta.required,
578
+ startedAt: meta.startedAt,
579
+ endedAt: new Date().toISOString(),
580
+ durationMs: Date.now() - meta.startedMs,
581
+ command
582
+ };
583
+ if (details !== undefined) {
584
+ result.details = details;
585
+ }
586
+ return result;
587
+ }
588
+ function requireInput(value, name) {
589
+ if (value === undefined || value.trim().length === 0) {
590
+ throw new Error(`Harness configuration missing ${name}. Set ${name} before running the required live smoke matrix.`);
591
+ }
592
+ return value;
593
+ }
594
+ function okText(result) {
595
+ const data = result.data;
596
+ return data?.responseText ?? data?.text ?? "";
597
+ }
598
+ function textEquals(actual, expected) {
599
+ return normalize(actual) === normalize(expected);
600
+ }
601
+ function includesText(actual, expected) {
602
+ return normalize(actual).includes(normalize(expected));
603
+ }
604
+ function includesUploadedFilename(actual, expected) {
605
+ const normalizedActual = normalize(actual);
606
+ const extensionIndex = expected.lastIndexOf(".");
607
+ if (extensionIndex === -1) {
608
+ return normalizedActual.includes(normalize(expected));
609
+ }
610
+ const stem = escapeRegExp(expected.slice(0, extensionIndex).toLowerCase());
611
+ const extension = escapeRegExp(expected.slice(extensionIndex).toLowerCase());
612
+ return new RegExp(`${stem}(?:\\(\\d+\\))?${extension}`).test(normalizedActual);
613
+ }
614
+ function normalize(text) {
615
+ return (text ?? "").trim().toLowerCase().replace(/[.!?]+$/g, "");
616
+ }
617
+ function escapeRegExp(text) {
618
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
619
+ }
620
+ function stepPreviewText(steps, id) {
621
+ const preview = steps?.find(step => step.id === id)?.dataPreview;
622
+ if (preview !== undefined && typeof preview === "object" && preview !== null) {
623
+ const data = preview;
624
+ if (typeof data.responseText === "string")
625
+ return data.responseText;
626
+ if (typeof data.text === "string")
627
+ return data.text;
628
+ }
629
+ return "";
630
+ }
631
+ function includesStep(steps, id) {
632
+ return steps?.some(step => step.id === id && step.ok) === true;
633
+ }
634
+ function responseCommand(response) {
635
+ return {
636
+ ok: response.status === "ok",
637
+ status: response.status,
638
+ data: response,
639
+ warnings: [],
640
+ context: { timestamp: new Date(response.created_at * 1000).toISOString() }
641
+ };
642
+ }
643
+ async function tempFile(name, body) {
644
+ const dir = await mkdtemp(join(tmpdir(), "chatgpt-live-smoke-"));
645
+ const file = join(dir, name);
646
+ await writeFile(file, body, "utf8");
647
+ return file;
648
+ }