wtagent 0.3.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 (43) hide show
  1. package/README.md +163 -9
  2. package/package.json +9 -8
  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 +2418 -201
  7. package/src/browser/cdp-browser.js +223 -25
  8. package/src/browser/cdp-state.js +8 -5
  9. package/src/browser/chatgpt-dom.js +179 -0
  10. package/src/browser/chatgpt-web-adapter.js +831 -69
  11. package/src/browser/claude-web-adapter.js +13 -0
  12. package/src/browser/fake-web-model-adapter.js +152 -3
  13. package/src/browser/gemini-web-adapter.js +71 -20
  14. package/src/browser/glm-web-adapter.js +2 -2
  15. package/src/browser/grok-web-adapter.js +35 -3
  16. package/src/browser/rendered-text.js +11 -0
  17. package/src/cli/i18n.js +50 -20
  18. package/src/cli/main.js +124 -40
  19. package/src/cli/prompt-input.js +68 -0
  20. package/src/image/browser-image-driver.js +238 -0
  21. package/src/image/browser-image-session-pool.js +96 -0
  22. package/src/image/image-generation-service.js +101 -0
  23. package/src/image/image-provider-driver.js +22 -0
  24. package/src/image/native-image-receiver.js +66 -0
  25. package/src/image/provider-registry.js +23 -0
  26. package/src/image/providers/chatgpt-image-driver.js +211 -0
  27. package/src/image/providers/gemini-image-driver.js +100 -0
  28. package/src/image/providers/grok-image-driver.js +193 -0
  29. package/src/platform/command-launcher.js +1 -1
  30. package/src/platform/paths.js +19 -0
  31. package/src/platform/windows-diagnostics.js +9 -16
  32. package/src/policy/path-guard.js +5 -1
  33. package/src/policy/policy-engine.js +10 -5
  34. package/src/protocol/envelope.js +74 -0
  35. package/src/protocol/markers.js +16 -0
  36. package/src/protocol/prompt-builder.js +12 -9
  37. package/src/protocol/xml-protocol.js +36 -18
  38. package/src/runtime/agent-runtime.js +1383 -238
  39. package/src/session/agent-session.js +1047 -121
  40. package/src/tools/default-tools.js +1 -1
  41. package/src/tools/image-tools.js +92 -0
  42. package/src/tools/registry.js +13 -1
  43. package/docs/technical-design.md +0 -866
@@ -22,6 +22,19 @@ export class ClaudeWebAdapter extends BaseWebAdapter {
22
22
  return /^\/chat\//;
23
23
  }
24
24
 
25
+ freshConversationUrlPattern() {
26
+ return /^\/new\/?$/;
27
+ }
28
+
29
+ pendingAttachmentLocators() {
30
+ return [
31
+ this.page.locator(
32
+ 'form [data-testid="file-thumbnail"], '
33
+ + 'form button[aria-label*="remove file" i]',
34
+ ),
35
+ ];
36
+ }
37
+
25
38
  composerLocators() {
26
39
  return [
27
40
  this.page.locator('[data-testid="chat-input"]'),
@@ -7,17 +7,110 @@ export class FakeWebModelAdapter {
7
7
  this.mode = null;
8
8
  this.conversationUrl = "https://chatgpt.com/";
9
9
  this.lastAssistantMessageId = null;
10
+ this.lastAssistantTurn = null;
10
11
  this.responseNumber = 0;
11
12
  this.startConversationCalls = [];
12
13
  this.startConversationOptions = [];
14
+ this.startConversationOutcome = null;
13
15
  this.sentAttachments = [];
16
+ this.sentOutboundIds = [];
17
+ this.targetId = "fake-target";
18
+ this.lastUserMessageId = null;
19
+ this.lastSendStatus = "not-submitted";
20
+ this.conversationIdentityListener = null;
21
+ this.pendingOutboundRecoverySupported = true;
22
+ this.recoveryTargetCalls = [];
23
+ this.reconciliationCalls = [];
24
+ this.reconciliationOutcome = null;
14
25
  // Records window-state calls so tests can assert restore/minimize ordering.
15
26
  this.windowStateCalls = [];
16
27
  }
17
28
 
18
- async launch(preferredUrl = null) {
29
+ async launch(preferredUrl = null, options = {}) {
19
30
  this.launched = true;
20
31
  this.lastLaunchUrl = preferredUrl;
32
+ this.lastLaunchOptions = options;
33
+ }
34
+
35
+ supportsPendingOutboundRecovery() {
36
+ return this.pendingOutboundRecoverySupported;
37
+ }
38
+
39
+ async launchRecoveryTarget(targetId) {
40
+ this.launched = true;
41
+ this.recoveryTargetCalls.push(targetId);
42
+ if (!targetId || targetId !== this.targetId) {
43
+ const error = new Error("The fake recovery target is unavailable.");
44
+ error.code = "RECOVERY_TARGET_UNAVAILABLE";
45
+ throw error;
46
+ }
47
+ return {
48
+ conversationUrl: this.conversationUrl,
49
+ targetId,
50
+ };
51
+ }
52
+
53
+ async reconcilePendingOutbound(options = {}) {
54
+ this.reconciliationCalls.push(options);
55
+ const outcome = typeof this.reconciliationOutcome === "function"
56
+ ? await this.reconciliationOutcome(options)
57
+ : this.reconciliationOutcome;
58
+ if (!outcome) {
59
+ const error = new Error("The fake pending outbound remains uncertain.");
60
+ error.code = "OUTBOUND_COMMIT_UNCERTAIN";
61
+ throw error;
62
+ }
63
+ if (outcome.conversationUrl) {
64
+ this.conversationUrl = outcome.conversationUrl;
65
+ }
66
+ if (outcome.conversationTargetId) {
67
+ this.targetId = outcome.conversationTargetId;
68
+ }
69
+ this.lastUserMessageId = outcome.userMessageId ?? this.lastUserMessageId;
70
+ if (outcome.status === "complete") {
71
+ this.lastAssistantMessageId = outcome.assistantMessageId
72
+ ?? this.lastAssistantMessageId;
73
+ this.lastAssistantTurn = outcome.assistantTurn ?? this.lastAssistantTurn;
74
+ }
75
+ return outcome;
76
+ }
77
+
78
+ classifyConversationUrl(value) {
79
+ try {
80
+ const url = new URL(value);
81
+ if (url.protocol !== "https:" || url.origin !== "https://chatgpt.com") {
82
+ return "invalid";
83
+ }
84
+ if (/^\/c\/WEB:/.test(url.pathname)) {
85
+ return "provisional";
86
+ }
87
+ if (/^\/c\//.test(url.pathname)) {
88
+ return "restorable";
89
+ }
90
+ return url.pathname === "/" ? "fresh" : "unknown";
91
+ } catch {
92
+ return "invalid";
93
+ }
94
+ }
95
+
96
+ setConversationIdentityListener(listener) {
97
+ this.conversationIdentityListener = listener;
98
+ }
99
+
100
+ getConversationIdentity() {
101
+ return {
102
+ conversationUrl: this.conversationUrl,
103
+ targetId: this.targetId,
104
+ kind: this.classifyConversationUrl(this.conversationUrl),
105
+ };
106
+ }
107
+
108
+ getLastSendStatus() {
109
+ return this.lastSendStatus;
110
+ }
111
+
112
+ async getLastUserMessageId() {
113
+ return this.lastUserMessageId;
21
114
  }
22
115
 
23
116
  async close() {
@@ -46,6 +139,19 @@ export class FakeWebModelAdapter {
46
139
  } else {
47
140
  this.conversationUrl = "https://chatgpt.com/";
48
141
  }
142
+ const configuredOutcome = typeof this.startConversationOutcome === "function"
143
+ ? await this.startConversationOutcome(conversationUrl, options)
144
+ : this.startConversationOutcome;
145
+ const isConversation = /^\/c\//.test(new URL(this.conversationUrl).pathname);
146
+ const outcome = configuredOutcome ?? {
147
+ status: isConversation ? "restored-existing" : "verified-fresh",
148
+ conversationUrl: this.conversationUrl,
149
+ targetId: this.targetId,
150
+ };
151
+ if (outcome.conversationUrl) {
152
+ this.conversationUrl = outcome.conversationUrl;
153
+ }
154
+ return outcome;
49
155
  }
50
156
 
51
157
  async selectMode(mode) {
@@ -63,13 +169,40 @@ export class FakeWebModelAdapter {
63
169
  return this.conversationUrl;
64
170
  }
65
171
 
66
- async sendMessage(text, { files = [] } = {}) {
172
+ async sendMessage(text, {
173
+ files = [],
174
+ outboundId = null,
175
+ requireAttachments = false,
176
+ } = {}) {
177
+ const priorUserMessageId = this.lastUserMessageId;
178
+ const priorAssistantMessageId = this.lastAssistantMessageId;
179
+ this.lastSendStatus = "commit-unknown";
67
180
  this.sentMessages.push(text);
68
181
  this.sentAttachments.push(files);
182
+ this.sentOutboundIds.push(outboundId);
69
183
  if (this.conversationUrl === "https://chatgpt.com/") {
70
184
  this.conversationUrl = "https://chatgpt.com/c/fake";
71
185
  }
72
- return { attachment: files.length ? { attached: files, failed: [] } : null };
186
+ this.lastSendStatus = "confirmed";
187
+ this.lastUserMessageId = `user-${this.sentMessages.length}`;
188
+ await this.conversationIdentityListener?.(this.getConversationIdentity());
189
+ return {
190
+ attachment: files.length ? { attached: files, failed: [] } : null,
191
+ userMessageId: this.lastUserMessageId,
192
+ userTurn: (this.sentMessages.length * 2) - 1,
193
+ conversationUrl: this.conversationUrl,
194
+ conversationTargetId: this.targetId,
195
+ assistantBaseline: {
196
+ ids: priorAssistantMessageId ? [priorAssistantMessageId] : [],
197
+ count: priorAssistantMessageId ? 1 : 0,
198
+ maxTurn: priorAssistantMessageId ? (this.responseNumber * 2) : null,
199
+ lastText: "",
200
+ },
201
+ preOutboundMarkerIds: [
202
+ priorUserMessageId,
203
+ priorAssistantMessageId,
204
+ ].filter(Boolean),
205
+ };
73
206
  }
74
207
 
75
208
  async waitForTurnComplete({ onDelta } = {}) {
@@ -79,6 +212,7 @@ export class FakeWebModelAdapter {
79
212
  const response = this.responses.shift();
80
213
  this.responseNumber += 1;
81
214
  this.lastAssistantMessageId = `assistant-${this.responseNumber}`;
215
+ this.lastAssistantTurn = this.responseNumber * 2;
82
216
  if (response instanceof Error) {
83
217
  throw response;
84
218
  }
@@ -89,4 +223,19 @@ export class FakeWebModelAdapter {
89
223
  async getLastAssistantMessageId() {
90
224
  return this.lastAssistantMessageId;
91
225
  }
226
+
227
+ async getLastAssistantTurn() {
228
+ return this.lastAssistantTurn;
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
+ }
92
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
 
@@ -25,6 +26,16 @@ export class GeminiWebAdapter extends BaseWebAdapter {
25
26
  return /^\/app\/[^/]+/;
26
27
  }
27
28
 
29
+ pendingAttachmentLocators() {
30
+ return [
31
+ this.page.locator(
32
+ '[data-test-id="textarea-wrapper"] .attachment-container, '
33
+ + '[data-test-id="textarea-wrapper"] '
34
+ + 'button[aria-label*="remove" i]',
35
+ ),
36
+ ];
37
+ }
38
+
28
39
  composerLocators() {
29
40
  return [
30
41
  this.page.locator(
@@ -114,24 +125,13 @@ export class GeminiWebAdapter extends BaseWebAdapter {
114
125
  // Read Gemini's rendered answer body before falling back to the enclosing
115
126
  // message-content. Attachment/source chips (for example a trailing "TXT")
116
127
  // live beside `.markdown` and are UI chrome, not model output.
117
- const markdown = message.locator("message-content .markdown").last();
128
+ const markdown = message.locator("message-content .markdown");
118
129
  if (await markdown.count().catch(() => 0) > 0) {
119
- const cleaned = await markdown.evaluate((element) => {
120
- const clone = element.cloneNode(true);
121
- clone.querySelectorAll(
122
- "sources-carousel-inline, source-inline-chip, source-footnote, "
123
- + ".hide-from-message-actions",
124
- ).forEach((item) => item.remove());
125
- return clone.innerText;
126
- }).catch(() => null);
127
- if (cleaned != null) {
128
- return cleaned;
129
- }
130
- return await markdown.innerText().catch(() => "");
130
+ return await markdown.evaluateAll(readRenderedBlocks);
131
131
  }
132
- const content = message.locator("message-content").last();
132
+ const content = message.locator("message-content");
133
133
  if (await content.count().catch(() => 0) > 0) {
134
- return await content.innerText().catch(() => "");
134
+ return await content.evaluateAll(readRenderedBlocks);
135
135
  }
136
136
  return await message.innerText().catch(() => "");
137
137
  }
@@ -153,7 +153,6 @@ export class GeminiWebAdapter extends BaseWebAdapter {
153
153
  const completed = await firstVisible([
154
154
  message.locator('[data-test-id="regenerate-button"]'),
155
155
  message.getByRole("button", { name: /^(Redo|Regenerate|重做)$/i }),
156
- message.getByRole("button", { name: /^(Copy|复制)$/i }),
157
156
  ]);
158
157
  return !completed;
159
158
  }
@@ -298,16 +297,66 @@ export class GeminiWebAdapter extends BaseWebAdapter {
298
297
  }
299
298
  }
300
299
 
301
- async submitComposer(composer) {
300
+ async submitComposer(composer, options = {}) {
302
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
+ }
303
309
  if (
304
310
  sendButton
305
311
  && await sendButton.isEnabled().catch(() => false)
306
- && await this.#clickWithNativePointer(sendButton)
312
+ && await this.#clickWithNativePointer(sendButton, options.assertActive)
307
313
  ) {
308
314
  return;
309
315
  }
310
- 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;
311
360
  }
312
361
 
313
362
  async dismissTransientOverlays() {
@@ -389,7 +438,8 @@ export class GeminiWebAdapter extends BaseWebAdapter {
389
438
  return normalized;
390
439
  }
391
440
 
392
- async #clickWithNativePointer(locator) {
441
+ async #clickWithNativePointer(locator, assertActive = () => {}) {
442
+ assertActive();
393
443
  await locator.scrollIntoViewIfNeeded().catch(() => null);
394
444
  const box = await locator.boundingBox().catch(() => null);
395
445
  if (!box || !this.page.mouse) {
@@ -399,6 +449,7 @@ export class GeminiWebAdapter extends BaseWebAdapter {
399
449
  const y = box.y + box.height / 2;
400
450
  await this.page.mouse.move(x, y, { steps: 8 });
401
451
  await this.page.waitForTimeout(75);
452
+ assertActive();
402
453
  await this.page.mouse.down();
403
454
  await this.page.waitForTimeout(80);
404
455
  await this.page.mouse.up();
@@ -152,8 +152,8 @@ export class GLMWebAdapter extends BaseWebAdapter {
152
152
  return 5;
153
153
  }
154
154
 
155
- sentUserWaitAttempts() {
156
- return 120;
155
+ sendConfirmationTimeoutMs() {
156
+ return 30_000;
157
157
  }
158
158
 
159
159
  }
@@ -1,5 +1,6 @@
1
1
  import { BaseWebAdapter, firstVisible } from "./base-web-adapter.js";
2
2
  import { isUsageLimitNotice } from "../shared/usage-limit.js";
3
+ import { readRenderedBlocks } from "./rendered-text.js";
3
4
 
4
5
  export { isConnectionLostError } from "./base-web-adapter.js";
5
6
 
@@ -20,7 +21,11 @@ export class GrokWebAdapter extends BaseWebAdapter {
20
21
 
21
22
  composerLocators() {
22
23
  return [
23
- this.page.locator("main textarea").first(),
24
+ // Grok replaces its initial textarea with a role=textbox editor during
25
+ // hydration. Keep both variants in one live locator so a composer found
26
+ // just before that replacement does not become an empty stale selector
27
+ // by the time sendMessage fills it.
28
+ this.page.locator('main textarea, main [role="textbox"]').first(),
24
29
  this.page.locator("textarea[aria-label]").first(),
25
30
  this.page.locator('[role="textbox"]').first(),
26
31
  ];
@@ -84,13 +89,40 @@ export class GrokWebAdapter extends BaseWebAdapter {
84
89
  );
85
90
  }
86
91
 
92
+ async userMessageText(message) {
93
+ const markdown = message.locator(".response-content-markdown");
94
+ if (await markdown.count().catch(() => 0) > 0) {
95
+ const exact = await markdown.evaluateAll(readRenderedBlocks);
96
+ if (exact.trim()) return exact;
97
+ }
98
+ return await super.userMessageText(message);
99
+ }
100
+
87
101
  async messageIdentity(message) {
88
102
  const testId = await message.getAttribute("data-testid").catch(() => null);
89
103
  const aria = await message.getAttribute("aria-label").catch(() => null);
90
104
  const scope = testId === "user-message" || aria === "You"
91
105
  ? this.userMessages()
92
106
  : this.assistantMessages();
93
- return { id: null, turn: await scope.count().catch(() => 0) };
107
+ // Grok wraps every visible user/assistant bubble in a stable response UUID:
108
+ //
109
+ // <div id="response-..." data-scroll-anchor-root>
110
+ // <div data-testid="user-message|assistant-message">...</div>
111
+ // </div>
112
+ //
113
+ // Keep the count fallback for older DOM variants, but prefer the wrapper
114
+ // identity whenever it is present. Besides making normal multi-turn
115
+ // correlation stronger, this lets a fresh chat safely follow Grok's late
116
+ // root -> /c/... navigation after the user bubble has already committed.
117
+ const id = await message.evaluate((element) => (
118
+ element.closest('[data-scroll-anchor-root][id^="response-"]')?.id
119
+ ?? element.closest('[id^="response-"]')?.id
120
+ ?? null
121
+ )).catch(() => null);
122
+ return {
123
+ id: id || null,
124
+ turn: await scope.count().catch(() => 0),
125
+ };
94
126
  }
95
127
 
96
128
  isNewAssistantIdentity({ turn, text }) {
@@ -104,7 +136,7 @@ export class GrokWebAdapter extends BaseWebAdapter {
104
136
  async assistantText(message) {
105
137
  const response = message.locator(".response-content-markdown");
106
138
  if (await response.count().catch(() => 0) > 0) {
107
- return await response.last().innerText().catch(() => "");
139
+ return await response.evaluateAll(readRenderedBlocks);
108
140
  }
109
141
  return await message.innerText().catch(() => "");
110
142
  }
@@ -0,0 +1,11 @@
1
+ // Passed directly to Locator.evaluateAll: keep this function self-contained.
2
+ // Read connected nodes so innerText keeps layout whitespace and omits hidden
3
+ // descendants. Cloning first changes innerText into textContent-like output.
4
+ export function readRenderedBlocks(elements) {
5
+ const roots = elements.filter((element) => (
6
+ !elements.some((other) => other !== element && other.contains(element))
7
+ ));
8
+ return roots.filter((element) => element.getClientRects().length > 0)
9
+ .map((element) => element.innerText)
10
+ .join("\n");
11
+ }