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.
@@ -593,7 +593,7 @@ sessions/<session-id>/
593
593
  tool-output.jsonl
594
594
  ```
595
595
 
596
- `session.json` 保存项目根目录、ChatGPT 会话 URL、当前 run phase、最近 turn、等待回填的工具结果和副作用恢复日志。它没有不可继续的 `completed task` 状态;`done=true` 只结束当前 run,Session 回到 `idle`。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wtagent",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Turn your own web AI session into a local tool-using agent.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- browser = await connectOverCDP(state.endpoint);
196
- const context = browser.contexts()[0];
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
  }
@@ -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.assistantCountBeforeSend = 0;
62
- this.assistantIdBeforeSend = null;
63
- this.assistantTextBeforeSend = "";
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(conversationUrl = null) {
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
- await this.page.goto(target, { waitUntil: "domcontentloaded" });
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
- this.assistantCountBeforeSend = await assistantMessages.count();
325
- if (this.assistantCountBeforeSend > 0) {
326
- const lastAssistant = assistantMessages.last();
327
- this.assistantIdBeforeSend = await lastAssistant
328
- .getAttribute("data-message-id")
329
- .catch(() => null);
330
- this.assistantTextBeforeSend = await this.#assistantText(lastAssistant);
331
- } else {
332
- this.assistantIdBeforeSend = null;
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 hasNewAssistant = count > this.assistantCountBeforeSend
437
- || (
438
- candidateId
439
- && candidateId !== this.assistantIdBeforeSend
440
- )
441
- || (
442
- candidateText
443
- && candidateText !== this.assistantTextBeforeSend
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.1")
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")
@@ -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(resume ? previousConversationUrl : null);
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 = mode;
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 ??= {};