wtagent 0.1.0-alpha.1 → 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/technical-design.md +5 -1
- package/package.json +1 -1
- package/src/browser/cdp-browser.js +70 -2
- package/src/browser/cdp-state.js +60 -0
- package/src/browser/chatgpt-web-adapter.js +189 -25
- package/src/browser/fake-web-model-adapter.js +3 -1
- package/src/cli/main.js +1 -1
- package/src/cli/render-events.js +12 -0
- package/src/runtime/agent-runtime.js +26 -4
- package/src/session/agent-session.js +4 -0
package/docs/technical-design.md
CHANGED
|
@@ -593,7 +593,7 @@ sessions/<session-id>/
|
|
|
593
593
|
tool-output.jsonl
|
|
594
594
|
```
|
|
595
595
|
|
|
596
|
-
`session.json` 保存项目根目录、ChatGPT 会话 URL
|
|
596
|
+
`session.json` 保存项目根目录、ChatGPT 会话 URL、最近确认的 assistant message ID、最近确认的实际模式、当前 run phase、最近 turn、等待回填的工具结果和副作用恢复日志。它没有不可继续的 `completed task` 状态;`done=true` 只结束当前 run,Session 回到 `idle`。
|
|
597
597
|
|
|
598
598
|
`rollout-*.jsonl` 从创建时起直接使用 **Codex rollout 风格**:首行 `session_meta`,后续每行 `{timestamp, type: "response_item", payload}`,payload 采用 OpenAI Responses 形状(`message` / `function_call` / `function_call_output`)。
|
|
599
599
|
|
|
@@ -628,6 +628,8 @@ sessionId + assistantMessageIdentity + normalizedToolCall
|
|
|
628
628
|
|
|
629
629
|
- Chrome 仍在:根据专用 Profile、PID、CDP 端口和健康检查验证身份,复用浏览器并创建新的 Page。
|
|
630
630
|
- Chrome 崩溃:用相同 Profile 重启并打开会话 URL。
|
|
631
|
+
- 同一 CLI 进程中的 follow-up 保持在当前会话 Page,不重复导航;跨进程恢复需要等待本地记录的最近 assistant message ID 出现在 DOM 后才能发送。
|
|
632
|
+
- 每次发送记录已有消息 ID 和新 user turn,只接受位于该 user turn 之后的新 assistant turn;无法建立可靠消息身份时超时并保存诊断,禁止退化为基于数量或文本变化猜测。
|
|
631
633
|
- 同一 Profile 同时只允许一个 WTAgent CLI Session;启动和接管过程使用 Profile 级互斥锁。
|
|
632
634
|
- 登录失效或出现验证:进入 `AUTH_REQUIRED`/`PAUSED`,让用户接管。
|
|
633
635
|
- 会话页面丢失:从本地记录打开原会话;无法恢复时创建新的本地 Session 和新的网页对话,不向旧 rollout 继续追加。
|
|
@@ -640,6 +642,8 @@ sessionId + assistantMessageIdentity + normalizedToolCall
|
|
|
640
642
|
2. 当前可用工具及参数 Schema。
|
|
641
643
|
3. 用户任务、项目根目录语义和执行边界。
|
|
642
644
|
|
|
645
|
+
同一网页会话中的普通 follow-up 只发送新的用户输入,并在末尾追加短 `<system_reminder>`;不得重复发送完整 `<agent_protocol>`、工具目录或初始任务。只有没有新用户输入的中断恢复流程可以发送完整 resume scaffold。
|
|
646
|
+
|
|
643
647
|
关键规则:
|
|
644
648
|
|
|
645
649
|
- 一轮最多一个工具调用。
|
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
discoverReusableCdpState,
|
|
8
8
|
fetchCdpVersion,
|
|
9
9
|
processMatchesCdpState,
|
|
10
|
+
reapStaleProfileChrome,
|
|
10
11
|
removeCdpState,
|
|
11
12
|
saveCdpState,
|
|
12
13
|
waitForProcessExit,
|
|
@@ -45,6 +46,33 @@ async function settleWithin(promise, timeoutMs) {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// Races a promise against a timeout. On timeout the returned promise rejects
|
|
50
|
+
// with a TIMEOUT-tagged error. Used to bound connectOverCDP + the first CDP
|
|
51
|
+
// round-trip: a Chrome whose profile is locked by a stale instance answers the
|
|
52
|
+
// WS handshake but never finishes protocol init, so an unbounded connect hangs
|
|
53
|
+
// for the full Playwright default (30s) before failing.
|
|
54
|
+
class CdpTimeoutError extends Error {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "CdpTimeoutError";
|
|
58
|
+
this.code = "CDP_CONNECT_TIMEOUT";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function withTimeout(promise, timeoutMs, message) {
|
|
63
|
+
let timer;
|
|
64
|
+
try {
|
|
65
|
+
return await Promise.race([
|
|
66
|
+
promise,
|
|
67
|
+
new Promise((_, reject) => {
|
|
68
|
+
timer = setTimeout(() => reject(new CdpTimeoutError(message)), timeoutMs);
|
|
69
|
+
}),
|
|
70
|
+
]);
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
async function waitForCdp({
|
|
49
77
|
endpoint,
|
|
50
78
|
child,
|
|
@@ -116,12 +144,14 @@ export async function launchAndConnectCdpChrome({
|
|
|
116
144
|
fetchVersion = fetchCdpVersion,
|
|
117
145
|
killTree = killProcessTree,
|
|
118
146
|
matchesState = processMatchesCdpState,
|
|
147
|
+
reapStale = reapStaleProfileChrome,
|
|
119
148
|
removeState = removeCdpState,
|
|
120
149
|
reserveCdpPort = reservePort,
|
|
121
150
|
saveState = saveCdpState,
|
|
122
151
|
spawnChrome = spawn,
|
|
123
152
|
waitForExit = waitForProcessExit,
|
|
124
153
|
waitForReady = waitForCdp,
|
|
154
|
+
connectTimeoutMs = 20_000,
|
|
125
155
|
} = {}) {
|
|
126
156
|
const releaseProfileLock = await acquireProfileLock(profileDir);
|
|
127
157
|
let child = null;
|
|
@@ -140,6 +170,11 @@ export async function launchAndConnectCdpChrome({
|
|
|
140
170
|
reused = true;
|
|
141
171
|
} else {
|
|
142
172
|
await removeState(profileDir);
|
|
173
|
+
// A prior instance may have died leaving renderer children (and Chrome's
|
|
174
|
+
// SingletonLock) still holding this profile. Reap those stale holders
|
|
175
|
+
// before launching, or the new Chrome hangs during profile init and
|
|
176
|
+
// connectOverCDP times out.
|
|
177
|
+
await reapStale(profileDir, { fetchVersion, killTree }).catch(() => null);
|
|
143
178
|
const port = await reserveCdpPort();
|
|
144
179
|
const endpoint = `http://127.0.0.1:${port}`;
|
|
145
180
|
child = spawnChrome(
|
|
@@ -192,8 +227,41 @@ export async function launchAndConnectCdpChrome({
|
|
|
192
227
|
}
|
|
193
228
|
}
|
|
194
229
|
|
|
195
|
-
|
|
196
|
-
|
|
230
|
+
// Bound the connect + first CDP round-trip. If Chrome's profile is held by
|
|
231
|
+
// a stale instance, the WS connects but protocol init never completes;
|
|
232
|
+
// without this guard Playwright hangs ~30s and leaves a dirty CDP state.
|
|
233
|
+
let context;
|
|
234
|
+
try {
|
|
235
|
+
browser = await withTimeout(
|
|
236
|
+
connectOverCDP(state.endpoint),
|
|
237
|
+
connectTimeoutMs,
|
|
238
|
+
`Timed out connecting to Chrome CDP at ${state.endpoint} after ${connectTimeoutMs}ms.`,
|
|
239
|
+
);
|
|
240
|
+
// contexts() forces a real protocol round-trip, so it hangs too when the
|
|
241
|
+
// browser main thread is stuck — keep it inside the timeout budget.
|
|
242
|
+
const contexts = await withTimeout(
|
|
243
|
+
Promise.resolve().then(() => browser.contexts()),
|
|
244
|
+
connectTimeoutMs,
|
|
245
|
+
`Timed out reading Chrome browser context at ${state.endpoint}.`,
|
|
246
|
+
);
|
|
247
|
+
context = contexts[0];
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error instanceof CdpTimeoutError) {
|
|
250
|
+
// The verified-but-unusable instance we launched is a dead end. Kill it
|
|
251
|
+
// (only if we own it) and drop its CDP state so the next run starts
|
|
252
|
+
// clean instead of trying to reuse a hung endpoint.
|
|
253
|
+
await browser?.close().catch(() => null);
|
|
254
|
+
if (!reused && child?.pid) {
|
|
255
|
+
await killTree(child.pid).catch(() => null);
|
|
256
|
+
}
|
|
257
|
+
await removeState(profileDir, state).catch(() => null);
|
|
258
|
+
throw new Error(
|
|
259
|
+
`${error.message} The Chrome profile may be held by another instance. `
|
|
260
|
+
+ "Close other windows using this profile, or run `wtagent logout` to reset it, then retry.",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
197
265
|
if (!context) {
|
|
198
266
|
throw new Error("Chrome CDP connection did not expose a browser context.");
|
|
199
267
|
}
|
package/src/browser/cdp-state.js
CHANGED
|
@@ -335,6 +335,66 @@ export async function discoverReusableCdpState(profileDir, {
|
|
|
335
335
|
);
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
+
// Kills Chrome processes bound to this profile whose CDP endpoint is dead, and
|
|
339
|
+
// clears Chrome's singleton guard files. A half-dead prior instance (main
|
|
340
|
+
// process gone or unresponsive, but renderer children still holding the
|
|
341
|
+
// profile) makes a freshly launched Chrome hang during profile initialization:
|
|
342
|
+
// the new CDP port answers HTTP/WS, but the browser main thread never becomes
|
|
343
|
+
// usable, so connectOverCDP times out. Reaping those stale holders first
|
|
344
|
+
// prevents the hang. Only ever touches processes that use THIS profile dir, and
|
|
345
|
+
// never a live/healthy CDP instance. Best-effort.
|
|
346
|
+
export async function reapStaleProfileChrome(profileDir, {
|
|
347
|
+
isAlive = isProcessAlive,
|
|
348
|
+
fetchVersion = fetchCdpVersion,
|
|
349
|
+
listProcesses = readProcessTable,
|
|
350
|
+
killTree = null,
|
|
351
|
+
} = {}) {
|
|
352
|
+
const resolvedProfile = path.resolve(profileDir);
|
|
353
|
+
let processes;
|
|
354
|
+
try {
|
|
355
|
+
processes = await listProcesses();
|
|
356
|
+
} catch {
|
|
357
|
+
return { killed: [] };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const candidates = processes
|
|
361
|
+
.map((entry) => candidateFromProcess(entry, resolvedProfile))
|
|
362
|
+
.filter(Boolean);
|
|
363
|
+
|
|
364
|
+
const killed = [];
|
|
365
|
+
for (const candidate of candidates) {
|
|
366
|
+
// "Stale" = the CDP endpoint does not answer a healthy version probe. A
|
|
367
|
+
// healthy instance is left alone (it is a reuse candidate elsewhere).
|
|
368
|
+
let healthy = false;
|
|
369
|
+
try {
|
|
370
|
+
await fetchVersion(`http://127.0.0.1:${candidate.port}`);
|
|
371
|
+
healthy = true;
|
|
372
|
+
} catch {
|
|
373
|
+
healthy = false;
|
|
374
|
+
}
|
|
375
|
+
if (healthy) {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (isAlive(candidate.pid) && typeof killTree === "function") {
|
|
379
|
+
await killTree(candidate.pid).catch(() => {});
|
|
380
|
+
}
|
|
381
|
+
killed.push(candidate.pid);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Chrome's singleton profile guard is a symlink; a crashed instance can leave
|
|
385
|
+
// it dangling and block the next launch. Clear it when we reaped holders or
|
|
386
|
+
// when it points at a dead pid.
|
|
387
|
+
const ownerPid = await singletonOwnerPid(resolvedProfile);
|
|
388
|
+
if (killed.length > 0 || (ownerPid && !isAlive(ownerPid))) {
|
|
389
|
+
for (const name of ["SingletonLock", "SingletonCookie", "SingletonSocket"]) {
|
|
390
|
+
await fs.rm(path.join(resolvedProfile, name), { force: true })
|
|
391
|
+
.catch(() => {});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { killed };
|
|
396
|
+
}
|
|
397
|
+
|
|
338
398
|
export async function acquireCdpProfileLock(profileDir, {
|
|
339
399
|
ownerPid = process.pid,
|
|
340
400
|
isAlive = isProcessAlive,
|
|
@@ -42,6 +42,22 @@ function hasCompleteAgentEnvelope(text) {
|
|
|
42
42
|
return start >= 0 && end >= start;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function sameConversationUrl(left, right) {
|
|
46
|
+
try {
|
|
47
|
+
const leftUrl = new URL(left);
|
|
48
|
+
const rightUrl = new URL(right);
|
|
49
|
+
return leftUrl.origin === rightUrl.origin
|
|
50
|
+
&& leftUrl.pathname === rightUrl.pathname;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseConversationTurn(value) {
|
|
57
|
+
const match = String(value ?? "").match(/^conversation-turn-(\d+)$/);
|
|
58
|
+
return match ? Number.parseInt(match[1], 10) : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
export class ChatGPTWebAdapter {
|
|
46
62
|
constructor({
|
|
47
63
|
profileDir,
|
|
@@ -58,9 +74,9 @@ export class ChatGPTWebAdapter {
|
|
|
58
74
|
this.context = null;
|
|
59
75
|
this.cdpChrome = null;
|
|
60
76
|
this.page = null;
|
|
61
|
-
this.
|
|
62
|
-
this.
|
|
63
|
-
this.
|
|
77
|
+
this.assistantIdsBeforeSend = new Set();
|
|
78
|
+
this.assistantMaxTurnBeforeSend = null;
|
|
79
|
+
this.sentUserTurn = null;
|
|
64
80
|
this.lastAssistantMessageId = null;
|
|
65
81
|
this.lastModeSelection = null;
|
|
66
82
|
}
|
|
@@ -147,7 +163,10 @@ export class ChatGPTWebAdapter {
|
|
|
147
163
|
);
|
|
148
164
|
}
|
|
149
165
|
|
|
150
|
-
async startConversation(
|
|
166
|
+
async startConversation(
|
|
167
|
+
conversationUrl = null,
|
|
168
|
+
{ expectedAssistantMessageId = null } = {},
|
|
169
|
+
) {
|
|
151
170
|
this.#requirePage();
|
|
152
171
|
let target = this.baseUrl;
|
|
153
172
|
if (conversationUrl) {
|
|
@@ -162,7 +181,13 @@ export class ChatGPTWebAdapter {
|
|
|
162
181
|
target = parsed.href;
|
|
163
182
|
}
|
|
164
183
|
|
|
165
|
-
|
|
184
|
+
const reuseCurrent = Boolean(
|
|
185
|
+
conversationUrl
|
|
186
|
+
&& sameConversationUrl(this.page.url(), target),
|
|
187
|
+
);
|
|
188
|
+
if (!reuseCurrent) {
|
|
189
|
+
await this.page.goto(target, { waitUntil: "domcontentloaded" });
|
|
190
|
+
}
|
|
166
191
|
const composer = await this.#waitForComposer(30_000);
|
|
167
192
|
if (!composer) {
|
|
168
193
|
throw new BrowserAdapterError(
|
|
@@ -171,6 +196,13 @@ export class ChatGPTWebAdapter {
|
|
|
171
196
|
);
|
|
172
197
|
}
|
|
173
198
|
|
|
199
|
+
if (conversationUrl) {
|
|
200
|
+
await this.#waitForConversationHistory({
|
|
201
|
+
expectedAssistantMessageId,
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
174
206
|
if (!conversationUrl && !await this.#isFreshConversation()) {
|
|
175
207
|
await this.#openNewConversation();
|
|
176
208
|
const freshComposer = await this.#waitForComposer(30_000);
|
|
@@ -321,17 +353,15 @@ export class ChatGPTWebAdapter {
|
|
|
321
353
|
}
|
|
322
354
|
|
|
323
355
|
const assistantMessages = this.#assistantMessages();
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
this.assistantTextBeforeSend = "";
|
|
334
|
-
}
|
|
356
|
+
const assistantBaseline = await this.#captureMessageIdentities(
|
|
357
|
+
assistantMessages,
|
|
358
|
+
);
|
|
359
|
+
const userBaseline = await this.#captureMessageIdentities(
|
|
360
|
+
this.#userMessages(),
|
|
361
|
+
);
|
|
362
|
+
this.assistantIdsBeforeSend = assistantBaseline.ids;
|
|
363
|
+
this.assistantMaxTurnBeforeSend = assistantBaseline.maxTurn;
|
|
364
|
+
this.sentUserTurn = null;
|
|
335
365
|
|
|
336
366
|
try {
|
|
337
367
|
await composer.fill(text);
|
|
@@ -367,6 +397,7 @@ export class ChatGPTWebAdapter {
|
|
|
367
397
|
).catch(() => null);
|
|
368
398
|
}
|
|
369
399
|
|
|
400
|
+
await this.#waitForSentUserMessage(userBaseline);
|
|
370
401
|
return { attachment };
|
|
371
402
|
}
|
|
372
403
|
|
|
@@ -433,15 +464,30 @@ export class ChatGPTWebAdapter {
|
|
|
433
464
|
const candidateId = lastMessage
|
|
434
465
|
? await lastMessage.getAttribute("data-message-id").catch(() => null)
|
|
435
466
|
: null;
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
467
|
+
const candidateTurn = lastMessage
|
|
468
|
+
? await this.#messageTurn(lastMessage)
|
|
469
|
+
: null;
|
|
470
|
+
let hasNewAssistant;
|
|
471
|
+
if (this.sentUserTurn != null && candidateTurn != null) {
|
|
472
|
+
// Strongest boundary: the answer must be a conversation turn after
|
|
473
|
+
// the exact user message that sendMessage() observed in the DOM.
|
|
474
|
+
hasNewAssistant = candidateTurn > this.sentUserTurn;
|
|
475
|
+
} else if (candidateId) {
|
|
476
|
+
// Stable ChatGPT message IDs are the next-best boundary. Never accept
|
|
477
|
+
// an ID that existed before this send, even if its text or DOM position
|
|
478
|
+
// changed during hydration.
|
|
479
|
+
hasNewAssistant = !this.assistantIdsBeforeSend.has(candidateId);
|
|
480
|
+
} else if (
|
|
481
|
+
candidateTurn != null
|
|
482
|
+
&& this.assistantMaxTurnBeforeSend != null
|
|
483
|
+
) {
|
|
484
|
+
hasNewAssistant = candidateTurn > this.assistantMaxTurnBeforeSend;
|
|
485
|
+
} else {
|
|
486
|
+
// Without a stable message ID or turn boundary we cannot prove this
|
|
487
|
+
// reply belongs to the current send. Timing/count heuristics caused the
|
|
488
|
+
// stale-answer bug, so fail closed and let the bounded wait time out.
|
|
489
|
+
hasNewAssistant = false;
|
|
490
|
+
}
|
|
445
491
|
|
|
446
492
|
if (hasNewAssistant) {
|
|
447
493
|
sawAssistant = true;
|
|
@@ -563,6 +609,124 @@ export class ChatGPTWebAdapter {
|
|
|
563
609
|
return this.page.locator('[data-message-author-role="assistant"]');
|
|
564
610
|
}
|
|
565
611
|
|
|
612
|
+
#userMessages() {
|
|
613
|
+
return this.page.locator('[data-message-author-role="user"]');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async #messageTurn(message) {
|
|
617
|
+
if (typeof message?.evaluate !== "function") {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
const testId = await message.evaluate((element) => (
|
|
621
|
+
element.closest('[data-testid^="conversation-turn-"]')
|
|
622
|
+
?.getAttribute("data-testid") ?? null
|
|
623
|
+
)).catch(() => null);
|
|
624
|
+
return parseConversationTurn(testId);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async #messageIdentity(message) {
|
|
628
|
+
const [id, turn] = await Promise.all([
|
|
629
|
+
message.getAttribute("data-message-id").catch(() => null),
|
|
630
|
+
this.#messageTurn(message),
|
|
631
|
+
]);
|
|
632
|
+
return { id, turn };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async #captureMessageIdentities(messages) {
|
|
636
|
+
const count = await messages.count().catch(() => 0);
|
|
637
|
+
const ids = new Set();
|
|
638
|
+
let maxTurn = null;
|
|
639
|
+
for (let index = 0; index < count; index += 1) {
|
|
640
|
+
const identity = await this.#messageIdentity(messages.nth(index));
|
|
641
|
+
if (identity.id) {
|
|
642
|
+
ids.add(identity.id);
|
|
643
|
+
}
|
|
644
|
+
if (identity.turn != null) {
|
|
645
|
+
maxTurn = maxTurn == null
|
|
646
|
+
? identity.turn
|
|
647
|
+
: Math.max(maxTurn, identity.turn);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return { count, ids, maxTurn };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async #waitForSentUserMessage(baseline, attempts = 50) {
|
|
654
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
655
|
+
const messages = this.#userMessages();
|
|
656
|
+
const count = await messages.count().catch(() => 0);
|
|
657
|
+
for (let index = count - 1; index >= 0; index -= 1) {
|
|
658
|
+
const identity = await this.#messageIdentity(messages.nth(index));
|
|
659
|
+
const newByTurn = identity.turn != null
|
|
660
|
+
&& (
|
|
661
|
+
baseline.maxTurn == null
|
|
662
|
+
|| identity.turn > baseline.maxTurn
|
|
663
|
+
);
|
|
664
|
+
const newById = Boolean(
|
|
665
|
+
identity.id
|
|
666
|
+
&& !baseline.ids.has(identity.id),
|
|
667
|
+
);
|
|
668
|
+
if (newByTurn || newById) {
|
|
669
|
+
this.sentUserTurn = identity.turn;
|
|
670
|
+
return identity;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
await this.page.waitForTimeout(100);
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async #waitForConversationHistory({
|
|
679
|
+
expectedAssistantMessageId = null,
|
|
680
|
+
attempts = 60,
|
|
681
|
+
} = {}) {
|
|
682
|
+
let previousSignature = null;
|
|
683
|
+
let stableChecks = 0;
|
|
684
|
+
|
|
685
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
686
|
+
const assistant = await this.#captureMessageIdentities(
|
|
687
|
+
this.#assistantMessages(),
|
|
688
|
+
);
|
|
689
|
+
if (
|
|
690
|
+
expectedAssistantMessageId
|
|
691
|
+
&& assistant.ids.has(expectedAssistantMessageId)
|
|
692
|
+
) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (!expectedAssistantMessageId) {
|
|
697
|
+
const totalMessages = await this.#conversationMessages()
|
|
698
|
+
.count()
|
|
699
|
+
.catch(() => 0);
|
|
700
|
+
const signature = [
|
|
701
|
+
totalMessages,
|
|
702
|
+
assistant.count,
|
|
703
|
+
assistant.maxTurn ?? "",
|
|
704
|
+
[...assistant.ids].join(","),
|
|
705
|
+
].join(":");
|
|
706
|
+
if (totalMessages > 0 && signature === previousSignature) {
|
|
707
|
+
stableChecks += 1;
|
|
708
|
+
if (stableChecks >= 3) {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
} else {
|
|
712
|
+
stableChecks = 0;
|
|
713
|
+
previousSignature = signature;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
await this.page.waitForTimeout(250);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
await this.#writeDiagnostics("conversation-history-mismatch");
|
|
721
|
+
const detail = expectedAssistantMessageId
|
|
722
|
+
? ` Expected assistant message ${expectedAssistantMessageId} was not found.`
|
|
723
|
+
: " Existing conversation history did not become stable.";
|
|
724
|
+
throw new BrowserAdapterError(
|
|
725
|
+
`ChatGPT conversation history could not be verified.${detail}`,
|
|
726
|
+
{ code: "CONVERSATION_HISTORY_MISMATCH" },
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
566
730
|
async #assistantText(message) {
|
|
567
731
|
// ChatGPT may render the model's XML inside one of several code blocks,
|
|
568
732
|
// alongside rich cards (weather, charts, etc.) or other markdown. Search
|
|
@@ -9,6 +9,7 @@ export class FakeWebModelAdapter {
|
|
|
9
9
|
this.lastAssistantMessageId = null;
|
|
10
10
|
this.responseNumber = 0;
|
|
11
11
|
this.startConversationCalls = [];
|
|
12
|
+
this.startConversationOptions = [];
|
|
12
13
|
this.sentAttachments = [];
|
|
13
14
|
// Records window-state calls so tests can assert restore/minimize ordering.
|
|
14
15
|
this.windowStateCalls = [];
|
|
@@ -36,8 +37,9 @@ export class FakeWebModelAdapter {
|
|
|
36
37
|
|
|
37
38
|
async waitForManualLogin() {}
|
|
38
39
|
|
|
39
|
-
async startConversation(conversationUrl = null) {
|
|
40
|
+
async startConversation(conversationUrl = null, options = {}) {
|
|
40
41
|
this.startConversationCalls.push(conversationUrl);
|
|
42
|
+
this.startConversationOptions.push(options);
|
|
41
43
|
if (conversationUrl) {
|
|
42
44
|
this.conversationUrl = conversationUrl;
|
|
43
45
|
} else {
|
package/src/cli/main.js
CHANGED
|
@@ -575,7 +575,7 @@ async function runExport(sessionId, options) {
|
|
|
575
575
|
const program = new Command()
|
|
576
576
|
.name("wtagent")
|
|
577
577
|
.description("Turn your web AI session into a local tool-using agent.")
|
|
578
|
-
.version("0.1.0-alpha.
|
|
578
|
+
.version("0.1.0-alpha.2")
|
|
579
579
|
.option("--home <path>", "Application data directory")
|
|
580
580
|
.option("--profile-dir <path>", "Dedicated Chrome profile directory")
|
|
581
581
|
.option("--chrome-path <path>", "Chrome/Chromium executable")
|
package/src/cli/render-events.js
CHANGED
|
@@ -64,6 +64,10 @@ export class Renderer {
|
|
|
64
64
|
// line so the several completion-shaped events never double-print.
|
|
65
65
|
this.handled = new Set();
|
|
66
66
|
this.toolStart = new Map();
|
|
67
|
+
// A renderer lives for the whole interactive CLI session. Runtime turns
|
|
68
|
+
// still emit lifecycle events when they resume the same web conversation,
|
|
69
|
+
// but those milestones are only useful on the first turn.
|
|
70
|
+
this.lifecycleShown = new Set();
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
// ---- spinner -----------------------------------------------------------
|
|
@@ -251,6 +255,10 @@ export class Renderer {
|
|
|
251
255
|
const { type, payload = {} } = event ?? {};
|
|
252
256
|
switch (type) {
|
|
253
257
|
case "browser.started":
|
|
258
|
+
if (this.lifecycleShown.has(type)) {
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
this.lifecycleShown.add(type);
|
|
254
262
|
this.status("Chrome started.");
|
|
255
263
|
break;
|
|
256
264
|
case "browser.auth_required":
|
|
@@ -272,6 +280,10 @@ export class Renderer {
|
|
|
272
280
|
break;
|
|
273
281
|
}
|
|
274
282
|
case "conversation.started":
|
|
283
|
+
if (this.lifecycleShown.has(type)) {
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
this.lifecycleShown.add(type);
|
|
275
287
|
if (payload.mode) {
|
|
276
288
|
this.status(`Conversation ready (${payload.mode}).`);
|
|
277
289
|
} else {
|
|
@@ -198,10 +198,19 @@ export class AgentRuntime {
|
|
|
198
198
|
await this.emit("browser.authenticated");
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
await this.adapter.startConversation(
|
|
201
|
+
await this.adapter.startConversation(
|
|
202
|
+
resume ? previousConversationUrl : null,
|
|
203
|
+
{
|
|
204
|
+
expectedAssistantMessageId: resume
|
|
205
|
+
? this.session.state.lastAssistantMessageId
|
|
206
|
+
: null,
|
|
207
|
+
},
|
|
208
|
+
);
|
|
202
209
|
// The actual mode may differ from the requested one (Pro limited, fallback,
|
|
203
210
|
// or switcher not found), so report what was really selected.
|
|
204
|
-
let activeMode =
|
|
211
|
+
let activeMode = resume
|
|
212
|
+
? (this.session.state.activeMode ?? null)
|
|
213
|
+
: null;
|
|
205
214
|
if (!resume) {
|
|
206
215
|
const modeResult = await this.adapter.selectMode(mode);
|
|
207
216
|
if (modeResult) {
|
|
@@ -226,6 +235,7 @@ export class AgentRuntime {
|
|
|
226
235
|
await this.session.update({
|
|
227
236
|
phase: "running",
|
|
228
237
|
conversationUrl: await this.adapter.getConversationUrl(),
|
|
238
|
+
activeMode,
|
|
229
239
|
});
|
|
230
240
|
await this.emit("conversation.started", {
|
|
231
241
|
url: this.session.state.conversationUrl,
|
|
@@ -251,6 +261,14 @@ export class AgentRuntime {
|
|
|
251
261
|
initialTranscript = [userMessage(instruction, messageOptions)];
|
|
252
262
|
}
|
|
253
263
|
initialKind = "pending_tool_result";
|
|
264
|
+
} else if (resume && instruction?.trim()) {
|
|
265
|
+
// The live ChatGPT conversation already contains the bootstrap protocol
|
|
266
|
+
// and tool catalog. A normal follow-up should be the user's message, not
|
|
267
|
+
// another several-thousand-character protocol bootstrap. sendMessage()
|
|
268
|
+
// still appends the short format reminder.
|
|
269
|
+
initialMessage = instruction.trim();
|
|
270
|
+
initialTranscript = [userMessage(instruction.trim(), messageOptions)];
|
|
271
|
+
initialKind = "follow_up";
|
|
254
272
|
} else if (resume) {
|
|
255
273
|
const prompt = buildResumePrompt({
|
|
256
274
|
instruction,
|
|
@@ -302,16 +320,22 @@ export class AgentRuntime {
|
|
|
302
320
|
});
|
|
303
321
|
},
|
|
304
322
|
});
|
|
323
|
+
const assistantMessageId = await this.adapter
|
|
324
|
+
.getLastAssistantMessageId?.() ?? null;
|
|
305
325
|
if (awaitingPendingAcknowledgement) {
|
|
306
326
|
await this.session.clearPendingToolResult();
|
|
307
327
|
awaitingPendingAcknowledgement = false;
|
|
308
328
|
}
|
|
309
329
|
await this.session.update({
|
|
310
330
|
conversationUrl: await this.adapter.getConversationUrl(),
|
|
331
|
+
// Null is meaningful: retaining an older ID would falsely prove only
|
|
332
|
+
// that stale history had hydrated on the next resume.
|
|
333
|
+
lastAssistantMessageId: assistantMessageId,
|
|
311
334
|
});
|
|
312
335
|
await this.emit("model.message_complete", {
|
|
313
336
|
turn: turnNumber,
|
|
314
337
|
raw,
|
|
338
|
+
assistantMessageId,
|
|
315
339
|
});
|
|
316
340
|
|
|
317
341
|
let parsed;
|
|
@@ -391,8 +415,6 @@ export class AgentRuntime {
|
|
|
391
415
|
continue;
|
|
392
416
|
}
|
|
393
417
|
|
|
394
|
-
const assistantMessageId = await this.adapter
|
|
395
|
-
.getLastAssistantMessageId?.() ?? null;
|
|
396
418
|
const identity = deriveToolIdentity({
|
|
397
419
|
sessionId: this.session.sessionId,
|
|
398
420
|
assistantMessageId,
|
|
@@ -179,6 +179,8 @@ export class AgentSession {
|
|
|
179
179
|
turn: 0,
|
|
180
180
|
runCount: 0,
|
|
181
181
|
conversationUrl: null,
|
|
182
|
+
lastAssistantMessageId: null,
|
|
183
|
+
activeMode: null,
|
|
182
184
|
createdAt: now,
|
|
183
185
|
updatedAt: now,
|
|
184
186
|
rolloutFile: rolloutFileName(now, sessionId),
|
|
@@ -243,6 +245,8 @@ export class AgentSession {
|
|
|
243
245
|
? "idle"
|
|
244
246
|
: (state.status ?? "idle");
|
|
245
247
|
state.runCount ??= 0;
|
|
248
|
+
state.lastAssistantMessageId ??= null;
|
|
249
|
+
state.activeMode ??= null;
|
|
246
250
|
state.rolloutFile ??= "transcript.jsonl";
|
|
247
251
|
state.lastMessage ??= state.finalMessage ?? null;
|
|
248
252
|
state.completedTools ??= {};
|