wtagent 0.4.0 → 1.0.0

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 (34) hide show
  1. package/README.md +161 -7
  2. package/package.json +5 -4
  3. package/src/artifacts/artifact-store.js +154 -0
  4. package/src/audio/music-artifact.js +66 -0
  5. package/src/audio/native-music-receiver.js +91 -0
  6. package/src/browser/base-web-adapter.js +354 -50
  7. package/src/browser/chatgpt-dom.js +179 -0
  8. package/src/browser/chatgpt-web-adapter.js +89 -125
  9. package/src/browser/fake-web-model-adapter.js +16 -1
  10. package/src/browser/gemini-web-adapter.js +61 -20
  11. package/src/browser/grok-web-adapter.js +35 -3
  12. package/src/browser/rendered-text.js +11 -0
  13. package/src/cli/i18n.js +40 -10
  14. package/src/cli/main.js +32 -3
  15. package/src/cli/prompt-input.js +68 -0
  16. package/src/image/browser-image-driver.js +238 -0
  17. package/src/image/browser-image-session-pool.js +96 -0
  18. package/src/image/image-generation-service.js +101 -0
  19. package/src/image/image-provider-driver.js +22 -0
  20. package/src/image/native-image-receiver.js +66 -0
  21. package/src/image/provider-registry.js +23 -0
  22. package/src/image/providers/chatgpt-image-driver.js +211 -0
  23. package/src/image/providers/gemini-image-driver.js +100 -0
  24. package/src/image/providers/grok-image-driver.js +193 -0
  25. package/src/policy/policy-engine.js +10 -5
  26. package/src/protocol/envelope.js +74 -0
  27. package/src/protocol/markers.js +16 -0
  28. package/src/protocol/prompt-builder.js +12 -9
  29. package/src/protocol/xml-protocol.js +36 -18
  30. package/src/runtime/agent-runtime.js +345 -65
  31. package/src/session/agent-session.js +73 -2
  32. package/src/tools/image-tools.js +92 -0
  33. package/src/tools/registry.js +13 -1
  34. package/docs/technical-design.md +0 -873
@@ -0,0 +1,179 @@
1
+ // All supported ChatGPT DOM shapes live here. Text and localized headings must
2
+ // never supply a role, message identity, or ordering key.
3
+ const legacyUser = '[data-message-author-role="user"]';
4
+ const legacyAssistant = '[data-message-author-role="assistant"]:not([id^="request-placeholder-"])';
5
+ const modern = (role) => `[data-chatgpt-search-unit-key$=":${role}"]:not([id^="request-placeholder-"]):not(:has([data-message-author-role])):not([data-message-author-role] *)`;
6
+ export const CHATGPT_DOM = {
7
+ user: { legacy: legacyUser, modern: modern("user") },
8
+ assistant: { legacy: legacyAssistant, modern: modern("assistant") },
9
+ conversationLegacy: '[data-message-author-role="user"], [data-message-author-role="assistant"]',
10
+ composers: [
11
+ { name: "prompt-textarea", selector: "#prompt-textarea" },
12
+ { name: "markdown-composer", selector: '[data-composer-markdown][contenteditable="true"][role="textbox"]' },
13
+ { name: "message-textarea-en", selector: 'textarea[placeholder*="Message" i]' },
14
+ { name: "message-textarea-zh", selector: 'textarea[placeholder*="消息"]' },
15
+ { name: "lexical-composer", selector: 'div[contenteditable="true"][data-lexical-editor="true"]' },
16
+ { name: "main-contenteditable", selector: 'main div[contenteditable="true"]' },
17
+ ],
18
+ send: '[data-testid="send-button"]',
19
+ stop: '[data-testid="stop-button"]',
20
+ };
21
+ CHATGPT_DOM.messages = [legacyUser, legacyAssistant, modern("user"), modern("assistant")].join(", ");
22
+
23
+ // This function is serialized into the page. Keep it self-contained, and pass
24
+ // the registry explicitly when requesting a capability sample.
25
+ export function readChatGPTDom(input) {
26
+ const options = input?.selector ? input : null;
27
+ const elements = typeof input === "string" || options
28
+ ? [...document.querySelectorAll(options?.selector ?? input)]
29
+ : Array.isArray(input) ? input : [input];
30
+ const structure = { legacy: 0, modern: 0, unknown: 0, ambiguousIds: 0 };
31
+ const entries = elements.map((element, domIndex) => {
32
+ const legacyRole = element.getAttribute("data-message-author-role");
33
+ const key = element.getAttribute("data-chatgpt-search-unit-key") ?? "";
34
+ const modern = key.match(/^fallback-turn-(\d+):(\d+):(user|assistant)$/);
35
+ const role = legacyRole ?? modern?.[3] ?? null;
36
+ structure[legacyRole ? "legacy" : modern ? "modern" : "unknown"] += 1;
37
+ const wrapper = element.closest('[data-testid^="conversation-turn-"]');
38
+ const legacyTurn = wrapper?.getAttribute("data-testid")?.match(/^conversation-turn-(\d+)$/);
39
+ const messageIds = [...new Set((element.getAttribute("data-chatgpt-search-message-ids") ?? "").split(/\s+/).filter(Boolean))];
40
+ const legacyId = element.getAttribute("data-message-id");
41
+ if (legacyId == null && messageIds.length > 1) structure.ambiguousIds += 1;
42
+ const id = legacyId ?? (messageIds.length === 1 ? messageIds[0] : null);
43
+ // Thinking/tool blocks occupy positions too. Only the group and role define
44
+ // turn order; duplicate roles within a group are rejected by validation.
45
+ const turn = legacyTurn ? Number(legacyTurn[1])
46
+ : modern ? Number(modern[1]) * 2 + (role === "user" ? 1 : 2) : null;
47
+ let renderedText = element.innerText ?? element.textContent ?? "";
48
+ if (role === "assistant" && !renderedText.includes("<agent_response")) {
49
+ const codeTexts = [...element.querySelectorAll("pre code")].map((node) => node.innerText ?? node.textContent ?? "");
50
+ const envelope = codeTexts.find((text) => text.includes("<agent_response") && text.includes("</agent_response>"))
51
+ ?? codeTexts.find((text) => text.includes("<agent_response"));
52
+ const markdown = [...element.querySelectorAll('.markdown, [data-markdown-text-style="assistant-message"]')].at(-1);
53
+ if (envelope) renderedText = envelope;
54
+ else if (markdown) renderedText = markdown.innerText ?? markdown.textContent ?? "";
55
+ }
56
+ return { domIndex, role, id, turn, renderedText };
57
+ });
58
+ const snapshot = { url: globalThis.location?.href ?? null, entries, structure };
59
+ if (options?.registry) {
60
+ const visible = (element) => element.getClientRects().length > 0
61
+ && getComputedStyle(element).visibility !== "hidden";
62
+ const count = (selector) => [...document.querySelectorAll(selector)].filter(visible).length;
63
+ snapshot.capabilities = {
64
+ readyState: document.readyState,
65
+ composers: options.registry.composers.filter(({ selector }) => count(selector) > 0).map(({ name }) => name),
66
+ sendButton: count(options.registry.send) > 0,
67
+ stopButton: count(options.registry.stop) > 0,
68
+ placeholderCount: document.querySelectorAll('[id^="request-placeholder-"]').length,
69
+ // Unknown suffixes are evidence of a renderer change, not valid messages.
70
+ unknownSearchUnits: [...document.querySelectorAll('[data-chatgpt-search-unit-key]')]
71
+ .filter((element) => !/^fallback-turn-\d+:\d+:(user|assistant)$/.test(element.getAttribute("data-chatgpt-search-unit-key"))).length,
72
+ };
73
+ }
74
+ return snapshot;
75
+ }
76
+
77
+ // Deliberately excludes message text, IDs, composer content, and raw attributes.
78
+ // This report may be collected even when screenshots/full HTML are disabled.
79
+ export function summarizeChatGPTDom(snapshot) {
80
+ if (!snapshot || !Array.isArray(snapshot.entries)) return { status: "unavailable" };
81
+ const entries = snapshot.entries;
82
+ const issues = [];
83
+ const countMissing = (test) => entries.filter(test).length;
84
+ const missing = {
85
+ role: countMissing((entry) => !["user", "assistant"].includes(entry.role)),
86
+ id: countMissing((entry) => typeof entry.id !== "string" || !entry.id.trim()),
87
+ turn: countMissing((entry) => !Number.isSafeInteger(entry.turn) || entry.turn < 0),
88
+ };
89
+ for (const [field, count] of Object.entries(missing)) if (count) issues.push(`missing-${field}`);
90
+ const ids = entries.map((entry) => entry.id).filter(Boolean);
91
+ if (new Set(ids).size !== ids.length) issues.push("duplicate-message-id");
92
+ if (entries.some((entry, index) => index > 0 && entry.turn <= entries[index - 1].turn)) issues.push("non-monotonic-turns");
93
+ if (snapshot.structure?.ambiguousIds) issues.push("ambiguous-message-ids");
94
+ if (snapshot.capabilities?.unknownSearchUnits) issues.push("unknown-search-unit-format");
95
+ if (snapshot.capabilities && !snapshot.capabilities.composers.length) issues.push("composer-not-found");
96
+ return {
97
+ status: issues.length ? "incomplete" : entries.length ? "recognized" : "empty",
98
+ structure: snapshot.structure,
99
+ capabilities: snapshot.capabilities,
100
+ counts: { total: entries.length, user: countMissing((entry) => entry.role === "user"), assistant: countMissing((entry) => entry.role === "assistant") },
101
+ missing,
102
+ issues,
103
+ };
104
+ }
105
+
106
+ function parseConversationTurn(value) {
107
+ const match = String(value ?? "").match(/^conversation-turn-(\d+)$/);
108
+ return match ? Number.parseInt(match[1], 10) : null;
109
+ }
110
+
111
+ export async function readChatGPTMessageIdentity(message) {
112
+ if (await message.getAttribute("data-chatgpt-search-unit-key").catch(() => null)) {
113
+ const snapshot = await message.evaluate(readChatGPTDom).catch(() => null);
114
+ const entry = snapshot?.entries?.[0];
115
+ if (!entry || !["user", "assistant"].includes(entry.role)
116
+ || !entry.id || !Number.isSafeInteger(entry.turn) || entry.turn < 0) {
117
+ return { id: null, turn: null };
118
+ }
119
+ return { id: entry.id, turn: entry.turn };
120
+ }
121
+ const [id, turn] = await Promise.all([
122
+ message.getAttribute("data-message-id").catch(() => null),
123
+ readLegacyTurn(message),
124
+ ]);
125
+ return { id, turn };
126
+ }
127
+
128
+ async function readLegacyTurn(message) {
129
+ if (typeof message?.evaluate !== "function") {
130
+ return null;
131
+ }
132
+ const testId = await message.evaluate((element) => (
133
+ element.closest('[data-testid^="conversation-turn-"]')
134
+ ?.getAttribute("data-testid") ?? null
135
+ )).catch(() => null);
136
+ return parseConversationTurn(testId);
137
+ }
138
+
139
+
140
+ export async function readChatGPTAssistantText(message, hasCompleteAgentEnvelope) {
141
+ if (await message.getAttribute("data-chatgpt-search-unit-key").catch(() => null)) {
142
+ const snapshot = await message.evaluate(readChatGPTDom).catch(() => null);
143
+ return snapshot?.entries?.[0]?.renderedText ?? "";
144
+ }
145
+ // Read the whole assistant turn first. A long XML response can contain
146
+ // Markdown fences inside CDATA; ChatGPT then splits the rendered response
147
+ // into many <pre><code> nodes and the first node contains the opening tag
148
+ // but not the closing tag. The parent innerText keeps the complete envelope
149
+ // and avoids one CDP round trip per nested code block on every poll.
150
+ const fullText = await message.innerText().catch(() => "");
151
+ if (fullText.includes("<agent_response")) {
152
+ return fullText;
153
+ }
154
+
155
+ // Rare fallback for alternate renderers where the parent text omits code
156
+ // contents: prefer a complete code-block envelope, but retain a partial
157
+ // one so streaming progress remains visible until its closing tag arrives.
158
+ const codeBlocks = message.locator("pre code");
159
+ const codeBlockCount = await codeBlocks.count();
160
+ let partialEnvelope = "";
161
+ for (let index = 0; index < codeBlockCount; index += 1) {
162
+ const code = await codeBlocks.nth(index).innerText().catch(() => "");
163
+ if (hasCompleteAgentEnvelope(code)) {
164
+ return code;
165
+ }
166
+ if (!partialEnvelope && code.includes("<agent_response")) {
167
+ partialEnvelope = code;
168
+ }
169
+ }
170
+ if (partialEnvelope) {
171
+ return partialEnvelope;
172
+ }
173
+
174
+ const markdown = message.locator(".markdown");
175
+ if (await markdown.count()) {
176
+ return await markdown.last().innerText().catch(() => "");
177
+ }
178
+ return fullText;
179
+ }
@@ -14,12 +14,15 @@ import { isUsageLimitNotice } from "../shared/usage-limit.js";
14
14
  // base module.
15
15
  export { isConnectionLostError } from "./base-web-adapter.js";
16
16
 
17
- const CHATGPT_URL = "https://chatgpt.com/";
17
+ import {
18
+ CHATGPT_DOM,
19
+ readChatGPTDom,
20
+ summarizeChatGPTDom,
21
+ readChatGPTMessageIdentity,
22
+ readChatGPTAssistantText,
23
+ } from "./chatgpt-dom.js";
18
24
 
19
- function parseConversationTurn(value) {
20
- const match = String(value ?? "").match(/^conversation-turn-(\d+)$/);
21
- return match ? Number.parseInt(match[1], 10) : null;
22
- }
25
+ const CHATGPT_URL = "https://chatgpt.com/";
23
26
 
24
27
  function normalizedConversationUrl(value) {
25
28
  try {
@@ -101,11 +104,46 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
101
104
  return kind;
102
105
  }
103
106
  const parsed = value instanceof URL ? value : new URL(value);
104
- return /^\/c\/WEB:/.test(parsed.pathname)
107
+ return /^\/c\/(?:WEB:|local-chatgpt(?::|%3[aA]))/.test(parsed.pathname)
105
108
  ? "provisional"
106
109
  : "restorable";
107
110
  }
108
111
 
112
+ async startConversation(...args) {
113
+ try {
114
+ const result = await super.startConversation(...args);
115
+ await this.collectStructuralDiagnostics();
116
+ return result;
117
+ } catch (error) {
118
+ const diagnostic = await this.writeDiagnostics("conversation-start-failed", { stage: "conversation-start" }).catch(() => null);
119
+ if (diagnostic?.filename) {
120
+ error.details = { ...error.details, diagnosticFile: diagnostic.filename, dom: diagnostic.report.dom };
121
+ }
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ hasStructuralDiagnostics() {
127
+ return true;
128
+ }
129
+
130
+ recordConversationNavigation(url) {
131
+ if (url === this.lastDiagnosticUrl) return;
132
+ this.lastDiagnosticUrl = url;
133
+ this.diagnosticRoutes ??= [];
134
+ this.diagnosticRoutes.push({ kind: this.classifyConversationUrl(url), at: Date.now() });
135
+ this.diagnosticRoutes = this.diagnosticRoutes.slice(-12);
136
+ }
137
+
138
+ async structuralDiagnostics() {
139
+ const snapshot = await this.page.evaluate(readChatGPTDom, {
140
+ selector: CHATGPT_DOM.messages, registry: CHATGPT_DOM,
141
+ });
142
+ const report = summarizeChatGPTDom(snapshot);
143
+ this.lastDomReport = report;
144
+ return report;
145
+ }
146
+
109
147
  pendingAttachmentLocators() {
110
148
  return [
111
149
  this.page.locator(
@@ -118,25 +156,19 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
118
156
  }
119
157
 
120
158
  composerLocators() {
121
- return [
122
- this.page.locator("#prompt-textarea"),
123
- this.page.locator('textarea[placeholder*="Message" i]'),
124
- this.page.locator('textarea[placeholder*="消息"]'),
125
- this.page.locator('div[contenteditable="true"][data-lexical-editor="true"]'),
126
- this.page.locator('main div[contenteditable="true"]'),
127
- ];
159
+ return CHATGPT_DOM.composers.map(({ selector }) => this.page.locator(selector));
128
160
  }
129
161
 
130
162
  sendButtonLocators() {
131
163
  return [
132
- this.page.locator('[data-testid="send-button"]'),
164
+ this.page.locator(CHATGPT_DOM.send),
133
165
  this.page.getByRole("button", { name: /send|发送/i }),
134
166
  ];
135
167
  }
136
168
 
137
169
  stopButtonLocators() {
138
170
  return [
139
- this.page.locator('[data-testid="stop-button"]'),
171
+ this.page.locator(CHATGPT_DOM.stop),
140
172
  this.page.getByRole("button", {
141
173
  name: /stop generating|stop|停止生成|停止/i,
142
174
  }),
@@ -182,19 +214,32 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
182
214
  // keeps a failed request from being read as an envelope-less reply and
183
215
  // burned through the protocol-error retry loop; the turn instead sees "no
184
216
  // reply" and recovers with the continuation nudge.
185
- return this.page.locator(
186
- '[data-message-author-role="assistant"]:not([id^="request-placeholder-"])',
187
- );
217
+ const legacy = this.page.locator(CHATGPT_DOM.assistant.legacy);
218
+ return legacy.or ? legacy.or(this.page.locator(CHATGPT_DOM.assistant.modern)) : legacy;
188
219
  }
189
220
 
190
221
  userMessages() {
191
- return this.page.locator('[data-message-author-role="user"]');
222
+ const legacy = this.page.locator(CHATGPT_DOM.user.legacy);
223
+ return legacy.or ? legacy.or(this.page.locator(CHATGPT_DOM.user.modern)) : legacy;
224
+ }
225
+
226
+ async userMessageSnapshot() {
227
+ const messages = this.userMessages();
228
+ if (typeof messages.evaluateAll !== "function") {
229
+ return super.userMessageSnapshot();
230
+ }
231
+ // ChatGPT unmounts older turns as a long thread advances. count() followed
232
+ // by nth() can then wait for a vanished index until the send deadline, or
233
+ // combine one bubble's identity with another bubble's text.
234
+ return await messages.evaluateAll(readChatGPTDom).then((snapshot) => (
235
+ snapshot.entries.every((entry) => entry.role === "user" && entry.id && Number.isSafeInteger(entry.turn))
236
+ ? snapshot.entries : null
237
+ )).catch(() => null);
192
238
  }
193
239
 
194
240
  conversationMessages() {
195
- return this.page.locator(
196
- '[data-message-author-role="user"], [data-message-author-role="assistant"]',
197
- );
241
+ const legacy = this.page.locator(CHATGPT_DOM.conversationLegacy);
242
+ return legacy.or ? legacy.or(this.page.locator(`${CHATGPT_DOM.user.modern}, ${CHATGPT_DOM.assistant.modern}`)) : legacy;
198
243
  }
199
244
 
200
245
  requiresOrderedConversationSnapshotForFreshSend() {
@@ -205,54 +250,9 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
205
250
  if (typeof this.page?.evaluate !== "function") {
206
251
  return null;
207
252
  }
208
- return await this.page.evaluate(() => {
209
- const entries = [
210
- ...document.querySelectorAll(
211
- '[data-message-author-role="user"], [data-message-author-role="assistant"]',
212
- ),
213
- ].filter((element) => {
214
- const role = element.getAttribute("data-message-author-role");
215
- return role === "user"
216
- || (role === "assistant" && !element.id.startsWith("request-placeholder-"));
217
- }).map((element, domIndex) => {
218
- const role = element.getAttribute("data-message-author-role");
219
- const wrapper = element.closest('[data-testid^="conversation-turn-"]');
220
- const turnMatch = wrapper?.getAttribute("data-testid")
221
- ?.match(/^conversation-turn-(\d+)$/);
222
- let renderedText = element.innerText ?? element.textContent ?? "";
223
- if (role === "assistant" && !renderedText.includes("<agent_response")) {
224
- const codeTexts = [...element.querySelectorAll("pre code")]
225
- .map((code) => code.innerText ?? code.textContent ?? "");
226
- const completeEnvelope = codeTexts.find((text) => {
227
- const start = text.trim().indexOf("<agent_response");
228
- const end = text.trim().lastIndexOf("</agent_response>");
229
- return start >= 0 && end >= start;
230
- });
231
- const partialEnvelope = codeTexts.find(
232
- (text) => text.includes("<agent_response"),
233
- );
234
- if (completeEnvelope) {
235
- renderedText = completeEnvelope;
236
- } else if (partialEnvelope) {
237
- renderedText = partialEnvelope;
238
- } else {
239
- const markdown = [...element.querySelectorAll(".markdown")];
240
- if (markdown.length > 0) {
241
- const last = markdown.at(-1);
242
- renderedText = last.innerText ?? last.textContent ?? "";
243
- }
244
- }
245
- }
246
- return {
247
- domIndex,
248
- role,
249
- id: element.getAttribute("data-message-id"),
250
- turn: turnMatch ? Number.parseInt(turnMatch[1], 10) : null,
251
- renderedText,
252
- };
253
- });
254
- return { url: window.location.href, entries };
255
- }).catch(() => null);
253
+ return await this.page.evaluate(readChatGPTDom,
254
+ CHATGPT_DOM.messages,
255
+ ).catch(() => null);
256
256
  }
257
257
 
258
258
  async assertRecoveredTurnTopology({
@@ -523,7 +523,19 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
523
523
  return found;
524
524
  }
525
525
 
526
- async reconcilePendingOutbound({
526
+ async reconcilePendingOutbound(options = {}) {
527
+ try {
528
+ return await this.#reconcilePendingOutbound(options);
529
+ } catch (error) {
530
+ const diagnostic = await this.writeDiagnostics("pending-outbound-recovery-failed", { stage: "recovery" }).catch(() => null);
531
+ if (diagnostic?.filename) {
532
+ error.details = { ...error.details, diagnosticFile: diagnostic.filename, dom: diagnostic.report.dom };
533
+ }
534
+ throw error;
535
+ }
536
+ }
537
+
538
+ async #reconcilePendingOutbound({
527
539
  pendingOutbound = null,
528
540
  conversationTargetId = null,
529
541
  lastUserMessageId = null,
@@ -718,7 +730,7 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
718
730
  );
719
731
  if (!generationError && !usageLimit) {
720
732
  rawResponse = assistant.renderedText;
721
- if (rawResponse.trim()) {
733
+ if (rawResponse.trim() || priorHandoff?.nativeImages?.length > 0) {
722
734
  responseHash = createHash("sha256")
723
735
  .update(rawResponse)
724
736
  .digest("hex");
@@ -904,22 +916,7 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
904
916
  }
905
917
 
906
918
  async messageIdentity(message) {
907
- const [id, turn] = await Promise.all([
908
- message.getAttribute("data-message-id").catch(() => null),
909
- this.#messageTurn(message),
910
- ]);
911
- return { id, turn };
912
- }
913
-
914
- async #messageTurn(message) {
915
- if (typeof message?.evaluate !== "function") {
916
- return null;
917
- }
918
- const testId = await message.evaluate((element) => (
919
- element.closest('[data-testid^="conversation-turn-"]')
920
- ?.getAttribute("data-testid") ?? null
921
- )).catch(() => null);
922
- return parseConversationTurn(testId);
919
+ return readChatGPTMessageIdentity(message);
923
920
  }
924
921
 
925
922
  // The redesigned UI still exposes the stop button ([data-testid="stop-button"],
@@ -957,40 +954,7 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
957
954
  }
958
955
 
959
956
  async assistantText(message) {
960
- // Read the whole assistant turn first. A long XML response can contain
961
- // Markdown fences inside CDATA; ChatGPT then splits the rendered response
962
- // into many <pre><code> nodes and the first node contains the opening tag
963
- // but not the closing tag. The parent innerText keeps the complete envelope
964
- // and avoids one CDP round trip per nested code block on every poll.
965
- const fullText = await message.innerText().catch(() => "");
966
- if (fullText.includes("<agent_response")) {
967
- return fullText;
968
- }
969
-
970
- // Rare fallback for alternate renderers where the parent text omits code
971
- // contents: prefer a complete code-block envelope, but retain a partial
972
- // one so streaming progress remains visible until its closing tag arrives.
973
- const codeBlocks = message.locator("pre code");
974
- const codeBlockCount = await codeBlocks.count();
975
- let partialEnvelope = "";
976
- for (let index = 0; index < codeBlockCount; index += 1) {
977
- const code = await codeBlocks.nth(index).innerText().catch(() => "");
978
- if (hasCompleteAgentEnvelope(code)) {
979
- return code;
980
- }
981
- if (!partialEnvelope && code.includes("<agent_response")) {
982
- partialEnvelope = code;
983
- }
984
- }
985
- if (partialEnvelope) {
986
- return partialEnvelope;
987
- }
988
-
989
- const markdown = message.locator(".markdown");
990
- if (await markdown.count()) {
991
- return await markdown.last().innerText().catch(() => "");
992
- }
993
- return fullText;
957
+ return readChatGPTAssistantText(message, hasCompleteAgentEnvelope);
994
958
  }
995
959
 
996
960
  // A plan/usage limit renders as an error card: error-tinted token classes
@@ -1110,8 +1074,8 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
1110
1074
  // Scrolls the thread container to the bottom so the latest replies mount.
1111
1075
  // Best-effort — any failure is swallowed.
1112
1076
  async scrollConversationToBottom() {
1113
- await this.page.evaluate?.(() => {
1114
- const message = document.querySelector("[data-message-author-role]");
1077
+ await this.page.evaluate?.((selector) => {
1078
+ const message = document.querySelector(selector);
1115
1079
  if (!message) {
1116
1080
  return;
1117
1081
  }
@@ -1126,7 +1090,7 @@ export class ChatGPTWebAdapter extends BaseWebAdapter {
1126
1090
  return;
1127
1091
  }
1128
1092
  }
1129
- }).catch(() => null);
1093
+ }, CHATGPT_DOM.messages).catch(() => null);
1130
1094
  }
1131
1095
 
1132
1096
  }
@@ -169,7 +169,11 @@ export class FakeWebModelAdapter {
169
169
  return this.conversationUrl;
170
170
  }
171
171
 
172
- async sendMessage(text, { files = [], outboundId = null } = {}) {
172
+ async sendMessage(text, {
173
+ files = [],
174
+ outboundId = null,
175
+ requireAttachments = false,
176
+ } = {}) {
173
177
  const priorUserMessageId = this.lastUserMessageId;
174
178
  const priorAssistantMessageId = this.lastAssistantMessageId;
175
179
  this.lastSendStatus = "commit-unknown";
@@ -223,4 +227,15 @@ export class FakeWebModelAdapter {
223
227
  async getLastAssistantTurn() {
224
228
  return this.lastAssistantTurn;
225
229
  }
230
+
231
+ async captureAuxiliaryTurnCompletion() {
232
+ this.responseNumber += 1;
233
+ this.lastAssistantMessageId = `assistant-${this.responseNumber}`;
234
+ this.lastAssistantTurn = this.responseNumber * 2;
235
+ return {
236
+ assistantMessageId: this.lastAssistantMessageId,
237
+ assistantTurn: this.lastAssistantTurn,
238
+ conversationUrl: this.conversationUrl,
239
+ };
240
+ }
226
241
  }
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { BaseWebAdapter, firstVisible } from "./base-web-adapter.js";
3
3
  import { BrowserAdapterError } from "../shared/errors.js";
4
4
  import { isUsageLimitNotice } from "../shared/usage-limit.js";
5
+ import { readRenderedBlocks } from "./rendered-text.js";
5
6
 
6
7
  export { isConnectionLostError } from "./base-web-adapter.js";
7
8
 
@@ -124,24 +125,13 @@ export class GeminiWebAdapter extends BaseWebAdapter {
124
125
  // Read Gemini's rendered answer body before falling back to the enclosing
125
126
  // message-content. Attachment/source chips (for example a trailing "TXT")
126
127
  // live beside `.markdown` and are UI chrome, not model output.
127
- const markdown = message.locator("message-content .markdown").last();
128
+ const markdown = message.locator("message-content .markdown");
128
129
  if (await markdown.count().catch(() => 0) > 0) {
129
- const cleaned = await markdown.evaluate((element) => {
130
- const clone = element.cloneNode(true);
131
- clone.querySelectorAll(
132
- "sources-carousel-inline, source-inline-chip, source-footnote, "
133
- + ".hide-from-message-actions",
134
- ).forEach((item) => item.remove());
135
- return clone.innerText;
136
- }).catch(() => null);
137
- if (cleaned != null) {
138
- return cleaned;
139
- }
140
- return await markdown.innerText().catch(() => "");
130
+ return await markdown.evaluateAll(readRenderedBlocks);
141
131
  }
142
- const content = message.locator("message-content").last();
132
+ const content = message.locator("message-content");
143
133
  if (await content.count().catch(() => 0) > 0) {
144
- return await content.innerText().catch(() => "");
134
+ return await content.evaluateAll(readRenderedBlocks);
145
135
  }
146
136
  return await message.innerText().catch(() => "");
147
137
  }
@@ -163,7 +153,6 @@ export class GeminiWebAdapter extends BaseWebAdapter {
163
153
  const completed = await firstVisible([
164
154
  message.locator('[data-test-id="regenerate-button"]'),
165
155
  message.getByRole("button", { name: /^(Redo|Regenerate|重做)$/i }),
166
- message.getByRole("button", { name: /^(Copy|复制)$/i }),
167
156
  ]);
168
157
  return !completed;
169
158
  }
@@ -308,16 +297,66 @@ export class GeminiWebAdapter extends BaseWebAdapter {
308
297
  }
309
298
  }
310
299
 
311
- async submitComposer(composer) {
300
+ async submitComposer(composer, options = {}) {
312
301
  const sendButton = await firstVisible(this.sendButtonLocators());
302
+ options.assertActive?.();
303
+ if (this.musicGeneration && sendButton && await sendButton.isEnabled()) {
304
+ // Gemini's current Music UI reliably activates through the focused
305
+ // button's keyboard handler. Submit once; never retry an unknown commit.
306
+ await sendButton.press("Space", { timeout: options.timeoutMs });
307
+ return;
308
+ }
313
309
  if (
314
310
  sendButton
315
311
  && await sendButton.isEnabled().catch(() => false)
316
- && await this.#clickWithNativePointer(sendButton)
312
+ && await this.#clickWithNativePointer(sendButton, options.assertActive)
317
313
  ) {
318
314
  return;
319
315
  }
320
- await super.submitComposer(composer);
316
+ await super.submitComposer(composer, options);
317
+ }
318
+
319
+ async prepareMusicGeneration() {
320
+ this.requirePage();
321
+ // Gemini may move Music from the regular chat tool menu to a dedicated
322
+ // creation screen. Its old tool chip then exists only in a hidden composer.
323
+ const prepareCreationPage = async () => {
324
+ if (typeof this.page.getByText !== "function") return false;
325
+ const heading = this.page.getByText("Create music", { exact: true });
326
+ if (!await heading.isVisible().catch(() => false)) return false;
327
+ const editor = this.page.locator('div.ql-editor[contenteditable="true"][role="textbox"]');
328
+ const template = this.page.getByRole("button", { name: "Background music", exact: true });
329
+ if (await template.isVisible().catch(() => false)) {
330
+ const placeholder = await editor.getAttribute("data-placeholder").catch(() => "");
331
+ if (!/background music/i.test(placeholder)) await template.click();
332
+ }
333
+ await editor.waitFor({ state: "visible", timeout: 15_000 });
334
+ this.musicGeneration = true;
335
+ return true;
336
+ };
337
+ if (await prepareCreationPage()) return;
338
+ try {
339
+ const selected = this.page.getByRole("button", {
340
+ name: /^(Deselect Music|取消选择音乐|取消選取音樂)$/i,
341
+ });
342
+ if (!await selected.isVisible().catch(() => false)) {
343
+ const tools = this.page.getByRole("button", { name: /^(Upload & tools|上传和工具|上傳和工具)$/i });
344
+ if (await tools.getAttribute("aria-expanded") !== "true") await tools.press("Space");
345
+ const music = this.page.locator('button[role="menuitemcheckbox"]:has([data-mat-icon-name="music"])');
346
+ await music.waitFor({ state: "visible", timeout: 10_000 });
347
+ if (await music.getAttribute("aria-disabled") === "true") {
348
+ throw new BrowserAdapterError("Gemini Music is unavailable for this account.", { code: "MUSIC_UNAVAILABLE", recoverable: false });
349
+ }
350
+ await music.press("Space");
351
+ await selected.waitFor({ state: "visible", timeout: 10_000 });
352
+ }
353
+ } catch (error) {
354
+ // Selecting Music in the old menu can navigate to the new screen before
355
+ // the old menu item appears. Reconcile the UI without sending twice.
356
+ if (await prepareCreationPage()) return;
357
+ throw error;
358
+ }
359
+ this.musicGeneration = true;
321
360
  }
322
361
 
323
362
  async dismissTransientOverlays() {
@@ -399,7 +438,8 @@ export class GeminiWebAdapter extends BaseWebAdapter {
399
438
  return normalized;
400
439
  }
401
440
 
402
- async #clickWithNativePointer(locator) {
441
+ async #clickWithNativePointer(locator, assertActive = () => {}) {
442
+ assertActive();
403
443
  await locator.scrollIntoViewIfNeeded().catch(() => null);
404
444
  const box = await locator.boundingBox().catch(() => null);
405
445
  if (!box || !this.page.mouse) {
@@ -409,6 +449,7 @@ export class GeminiWebAdapter extends BaseWebAdapter {
409
449
  const y = box.y + box.height / 2;
410
450
  await this.page.mouse.move(x, y, { steps: 8 });
411
451
  await this.page.waitForTimeout(75);
452
+ assertActive();
412
453
  await this.page.mouse.down();
413
454
  await this.page.waitForTimeout(80);
414
455
  await this.page.mouse.up();