wtagent 0.2.2 → 0.4.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.
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import {
3
3
  cdata,
4
4
  extractTrailingProse,
@@ -7,7 +7,10 @@ import {
7
7
  serializeToolResult,
8
8
  stripUiNoiseLines,
9
9
  } from "../protocol/xml-protocol.js";
10
- import { appendSystemReminder } from "../protocol/markers.js";
10
+ import {
11
+ appendSystemReminder,
12
+ DEFAULT_SYSTEM_REMINDER,
13
+ } from "../protocol/markers.js";
11
14
  import {
12
15
  buildBootstrapPrompt,
13
16
  buildResumePrompt,
@@ -16,21 +19,21 @@ import {
16
19
  assistantMessage,
17
20
  functionCall,
18
21
  functionCallOutput,
22
+ messageText,
19
23
  toolResultOutput,
20
24
  userMessage,
21
25
  } from "../session/canonical-transcript.js";
22
- import {
23
- DEFAULT_LIMITS,
24
- PRO_MODEL_TURN_TIMEOUT_MS,
25
- isProMode,
26
- } from "../shared/limits.js";
26
+ import { DEFAULT_LIMITS } from "../shared/limits.js";
27
27
  import { utf8ByteLength } from "../shared/text-budget.js";
28
28
  import {
29
29
  BrowserAdapterError,
30
30
  ProtocolError,
31
31
  ToolValidationError,
32
32
  } from "../shared/errors.js";
33
- import { isConnectionLostError } from "../browser/base-web-adapter.js";
33
+ import {
34
+ isConnectionLostError,
35
+ isValidOutboundCorrelationId,
36
+ } from "../browser/base-web-adapter.js";
34
37
  import { isUsageLimitNotice } from "../shared/usage-limit.js";
35
38
 
36
39
  const EMPTY_ASSISTANT_CONTINUE_MESSAGE =
@@ -48,6 +51,11 @@ const GENERATION_FAILED_CONTINUE_MESSAGE =
48
51
  + "Retry the immediately preceding task from the existing conversation context. Do not "
49
52
  + "repeat any local tool operation whose result is already present. Reply using the "
50
53
  + "required <agent_response> XML protocol.";
54
+ const OUTBOUND_CORRELATION_PREFIX =
55
+ " Opaque WTAgent transport correlation ID (do not repeat): ";
56
+ const OUTBOUND_CORRELATION_RESERVE_BYTES = utf8ByteLength(
57
+ `${OUTBOUND_CORRELATION_PREFIX}${"0".repeat(36)}.`,
58
+ );
51
59
 
52
60
  function canonicalize(value) {
53
61
  if (Array.isArray(value)) {
@@ -148,6 +156,7 @@ export class AgentRuntime {
148
156
  session,
149
157
  approval,
150
158
  onEvent,
159
+ postAuthSetup = null,
151
160
  limits = DEFAULT_LIMITS,
152
161
  }) {
153
162
  this.adapter = adapter;
@@ -156,7 +165,13 @@ export class AgentRuntime {
156
165
  this.session = session;
157
166
  this.approval = approval;
158
167
  this.onEvent = onEvent;
168
+ this.postAuthSetup = postAuthSetup;
159
169
  this.limits = limits;
170
+ this.conversationIdentityQueue = Promise.resolve();
171
+ this.deferConversationIdentityPersistence = false;
172
+ this.adapter.setConversationIdentityListener?.((identity) =>
173
+ this.#queueConversationIdentity(identity)
174
+ );
160
175
  }
161
176
 
162
177
  async emit(type, payload = {}) {
@@ -165,60 +180,409 @@ export class AgentRuntime {
165
180
  return event;
166
181
  }
167
182
 
168
- async sendMessage(text, { files = [], maxBytes = null } = {}) {
169
- const message = appendSystemReminder(text);
183
+ async sendMessage(text, {
184
+ files = [],
185
+ maxBytes = null,
186
+ allowFreshReconnect = false,
187
+ allowAssistantContinuation = false,
188
+ outboundKind = "runtime_message",
189
+ transcriptItems = [],
190
+ runtimeTurn = null,
191
+ pendingToolAcknowledgement = null,
192
+ } = {}) {
193
+ const outboundId = randomUUID();
194
+ const message = appendSystemReminder(
195
+ text,
196
+ `${DEFAULT_SYSTEM_REMINDER}${OUTBOUND_CORRELATION_PREFIX}${outboundId}.`,
197
+ );
198
+ const pendingOutbound = {
199
+ kind: outboundKind,
200
+ outboundId,
201
+ messageHash: createHash("sha256").update(message).digest("hex"),
202
+ preparedAt: new Date().toISOString(),
203
+ transcriptItems,
204
+ runtimeTurn: Number.isSafeInteger(runtimeTurn) ? runtimeTurn : null,
205
+ pendingToolAcknowledgement,
206
+ conversationUrl: this.session.state.conversationUrl ?? null,
207
+ conversationTargetId: this.session.state.conversationTargetId ?? null,
208
+ };
209
+ await this.#queueSessionUpdate({ pendingOutbound });
170
210
  try {
171
- await this.#sendMessageWithReconnect(message, { files, maxBytes });
211
+ const sendResult = await this.#sendMessageWithReconnect(message, {
212
+ files,
213
+ maxBytes,
214
+ allowFreshReconnect,
215
+ allowAssistantContinuation,
216
+ outboundId,
217
+ });
218
+ const handoff = await this.#queueSessionOperation(() => (
219
+ this.session.commitPendingOutboundHandoff({
220
+ outboundId,
221
+ handoff: {
222
+ runtimeTurn: pendingOutbound.runtimeTurn
223
+ ?? this.session.state.turn,
224
+ conversationUrl: sendResult?.conversationUrl
225
+ ?? this.session.state.conversationUrl,
226
+ conversationTargetId: sendResult?.conversationTargetId
227
+ ?? this.session.state.conversationTargetId,
228
+ userMessageId: sendResult?.userMessageId ?? null,
229
+ userTurn: sendResult?.userTurn ?? null,
230
+ assistantBaseline: sendResult?.assistantBaseline ?? null,
231
+ preOutboundMarkerIds: sendResult?.preOutboundMarkerIds ?? [],
232
+ pendingToolAcknowledgement,
233
+ },
234
+ })
235
+ ));
236
+ return { ...sendResult, pendingAssistantTurn: handoff };
237
+ } catch (error) {
238
+ const sendStatus = this.adapter.getLastSendStatus?.() ?? "commit-unknown";
239
+ const nextPending = sendStatus === "not-submitted"
240
+ ? null
241
+ : {
242
+ ...pendingOutbound,
243
+ status: "commit-unknown",
244
+ failedAt: new Date().toISOString(),
245
+ };
246
+ await this.#queueSessionOperation(async () => {
247
+ if (this.session.state.pendingOutbound?.outboundId === outboundId) {
248
+ await this.session.update({ pendingOutbound: nextPending });
249
+ }
250
+ }).catch(() => {});
251
+ throw error;
172
252
  } finally {
173
- const conversationUrl = await this.adapter.getConversationUrl()
253
+ // URL observation must never replace the send result. Top-frame navigation
254
+ // is persisted eagerly; this is only a best-effort final checkpoint.
255
+ const identity = await this.#syncConversationIdentity({
256
+ suppressErrors: true,
257
+ });
258
+ await this.#refreshPendingOutboundIdentity(outboundId, identity)
174
259
  .catch(() => null);
260
+ }
261
+ }
262
+
263
+ async #persistConversationIdentity(identity, { allowFresh = false } = {}) {
264
+ const conversationUrl = identity?.conversationUrl ?? null;
265
+ const kind = identity?.kind
266
+ ?? (conversationUrl && this.adapter.classifyConversationUrl?.(conversationUrl));
267
+ if (
268
+ !conversationUrl
269
+ || (
270
+ !["restorable", "provisional"].includes(kind)
271
+ && !(allowFresh && kind === "fresh")
272
+ )
273
+ ) {
274
+ return null;
275
+ }
276
+
277
+ const targetId = typeof identity?.targetId === "string"
278
+ && identity.targetId
279
+ ? identity.targetId
280
+ : null;
281
+ const patch = {};
282
+ if (conversationUrl !== this.session.state.conversationUrl) {
283
+ patch.conversationUrl = conversationUrl;
284
+ }
285
+ if (
286
+ targetId
287
+ && targetId !== this.session.state.conversationTargetId
288
+ ) {
289
+ patch.conversationTargetId = targetId;
290
+ }
291
+ if (Object.keys(patch).length > 0) {
292
+ await this.session.update(patch);
293
+ }
294
+ return {
295
+ conversationUrl,
296
+ targetId: targetId ?? this.session.state.conversationTargetId ?? null,
297
+ kind,
298
+ };
299
+ }
300
+
301
+ #queueConversationIdentity(identity, options = {}) {
302
+ if (this.deferConversationIdentityPersistence) {
303
+ return Promise.resolve(null);
304
+ }
305
+ const operation = this.conversationIdentityQueue
306
+ .catch(() => null)
307
+ .then(() => this.#persistConversationIdentity(identity, options));
308
+ this.conversationIdentityQueue = operation;
309
+ return operation;
310
+ }
311
+
312
+ #queueSessionOperation(callback) {
313
+ const operation = this.conversationIdentityQueue
314
+ .catch(() => null)
315
+ .then(callback);
316
+ this.conversationIdentityQueue = operation;
317
+ return operation;
318
+ }
319
+
320
+ #queueSessionUpdate(patch) {
321
+ return this.#queueSessionOperation(() => this.session.update(patch));
322
+ }
323
+
324
+ async #refreshPendingOutboundIdentity(
325
+ outboundId,
326
+ identity = null,
327
+ { required = false } = {},
328
+ ) {
329
+ return await this.#queueSessionOperation(async () => {
330
+ const current = this.session.state.pendingOutbound;
331
+ if (!current || current.outboundId !== outboundId) {
332
+ if (required) {
333
+ throw new BrowserAdapterError(
334
+ "The pending outbound changed before its reconnect target could be checkpointed.",
335
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
336
+ );
337
+ }
338
+ return null;
339
+ }
340
+ const conversationUrl = identity?.conversationUrl
341
+ ?? this.session.state.conversationUrl
342
+ ?? current.conversationUrl
343
+ ?? null;
344
+ const conversationTargetId = identity?.targetId
345
+ ?? identity?.conversationTargetId
346
+ ?? this.session.state.conversationTargetId
347
+ ?? current.conversationTargetId
348
+ ?? null;
175
349
  if (
176
- conversationUrl
177
- && conversationUrl !== this.session.state.conversationUrl
350
+ conversationUrl === current.conversationUrl
351
+ && conversationTargetId === current.conversationTargetId
178
352
  ) {
179
- await this.session.update({ conversationUrl });
353
+ return structuredClone(current);
180
354
  }
355
+ const refreshed = {
356
+ ...current,
357
+ conversationUrl,
358
+ conversationTargetId,
359
+ };
360
+ await this.session.update({
361
+ pendingOutbound: refreshed,
362
+ conversationUrl,
363
+ conversationTargetId,
364
+ });
365
+ return structuredClone(refreshed);
366
+ });
367
+ }
368
+
369
+ async #syncConversationIdentity({
370
+ suppressErrors = false,
371
+ allowFresh = false,
372
+ identity = null,
373
+ } = {}) {
374
+ // Observe and persist inside one queue entry. If a navigation callback fires
375
+ // while observation awaits the adapter, it queues after this operation and
376
+ // remains authoritative instead of being overwritten by a stale snapshot.
377
+ const operation = this.conversationIdentityQueue
378
+ .catch(() => null)
379
+ .then(async () => {
380
+ let observed = identity;
381
+ if (!observed) {
382
+ if (typeof this.adapter.getConversationIdentity === "function") {
383
+ observed = await this.adapter.getConversationIdentity();
384
+ } else {
385
+ const conversationUrl = await this.adapter.getConversationUrl();
386
+ observed = {
387
+ conversationUrl,
388
+ targetId: null,
389
+ kind: this.adapter.classifyConversationUrl?.(conversationUrl)
390
+ ?? (/\/c\//.test(new URL(conversationUrl).pathname)
391
+ ? "restorable"
392
+ : "fresh"),
393
+ };
394
+ }
395
+ }
396
+ return await this.#persistConversationIdentity(observed, { allowFresh });
397
+ });
398
+ this.conversationIdentityQueue = operation;
399
+ try {
400
+ return await operation;
401
+ } catch (error) {
402
+ if (!suppressErrors) {
403
+ throw error;
404
+ }
405
+ return null;
181
406
  }
182
407
  }
183
408
 
184
- // Sends once; if the browser connection died in between (e.g. the Mac slept
185
- // while a tool was running), reconnects to the still-alive Chrome, restores
186
- // the conversation, and retries once. A send that never rendered (ChatGPT
187
- // did not register the message) is also retried once — the deterministic
188
- // message is simply sent again. Anything else propagates.
189
- async #sendMessageWithReconnect(message, { files, maxBytes }) {
409
+ // Retry only when the adapter proves submission never started. Once the Send
410
+ // action has occurred, a dead transport or missing bubble is commit-unknown;
411
+ // replaying the payload could duplicate provider work and attachments.
412
+ async #sendMessageWithReconnect(message, {
413
+ files,
414
+ maxBytes,
415
+ allowFreshReconnect,
416
+ allowAssistantContinuation,
417
+ outboundId,
418
+ }) {
190
419
  try {
191
- await this.adapter.sendMessage(message, { files, maxBytes });
420
+ return await this.adapter.sendMessage(message, {
421
+ files,
422
+ maxBytes,
423
+ outboundId,
424
+ allowAssistantContinuation,
425
+ });
192
426
  } catch (error) {
193
427
  if (isConnectionLostError(error)) {
194
- await this.#reconnectAndRestore();
195
- await this.adapter.sendMessage(message, { files, maxBytes });
196
- return;
197
- }
198
- if (error?.code === "SEND_NOT_DETECTED") {
199
- await this.adapter.sendMessage(message, { files, maxBytes });
200
- return;
428
+ const sendStatus = this.adapter.getLastSendStatus?.() ?? "commit-unknown";
429
+ if (sendStatus !== "not-submitted") {
430
+ throw new BrowserAdapterError(
431
+ `${this.adapter.providerName ?? "Web provider"} connection was lost after submission may have started. `
432
+ + "The message was not sent again automatically.",
433
+ {
434
+ code: "SEND_COMMIT_UNKNOWN",
435
+ recoverable: false,
436
+ cause: error,
437
+ },
438
+ );
439
+ }
440
+ await this.#reconnectAndRestore({
441
+ allowFresh: allowFreshReconnect,
442
+ checkpointIdentity: (restoration) => (
443
+ this.#refreshPendingOutboundIdentity(
444
+ outboundId,
445
+ restoration,
446
+ { required: true },
447
+ )
448
+ ),
449
+ });
450
+ return await this.adapter.sendMessage(message, {
451
+ files,
452
+ maxBytes,
453
+ outboundId,
454
+ allowAssistantContinuation,
455
+ });
201
456
  }
202
457
  throw error;
203
458
  }
204
459
  }
205
460
 
206
- // Reconnects to the still-alive Chrome and navigates back to the session's
207
- // conversation, so the DOM state needed by the adapter is restored.
208
- async #reconnectAndRestore() {
209
- await this.adapter.reconnect?.(this.session.state.conversationUrl);
210
- await this.adapter.startConversation(
211
- this.session.state.conversationUrl,
212
- {
213
- expectedAssistantMessageId: this.session.state.lastAssistantMessageId
214
- ?? null,
215
- },
461
+ // Reconnects to the still-alive Chrome and restores the exact page target when
462
+ // possible. Once a context-dependent message has been sent, landing on a fresh
463
+ // page is never a safe restoration.
464
+ async #reconnectAndRestore({
465
+ allowFresh = false,
466
+ checkpointIdentity = null,
467
+ } = {}) {
468
+ // Navigation callbacks must not persist a replacement root target without
469
+ // the pending record that causally owns it. Suppress them until the caller's
470
+ // checkpoint writes both identities in one Session transition.
471
+ this.deferConversationIdentityPersistence = true;
472
+ try {
473
+ await this.conversationIdentityQueue.catch(() => null);
474
+ await this.adapter.reconnect?.(
475
+ this.session.state.conversationUrl,
476
+ { preferredTargetId: this.session.state.conversationTargetId ?? null },
477
+ );
478
+ const restoration = await this.adapter.startConversation(
479
+ this.session.state.conversationUrl,
480
+ {
481
+ expectedAssistantMessageId: this.session.state.lastAssistantMessageId
482
+ ?? null,
483
+ expectedUserMessageId: this.session.state.lastUserMessageId ?? null,
484
+ },
485
+ );
486
+ if (
487
+ restoration?.status !== "restored-existing"
488
+ && !(allowFresh && restoration?.status === "verified-fresh")
489
+ ) {
490
+ throw new BrowserAdapterError(
491
+ `${this.adapter.providerName} conversation disappeared while a turn was in progress. `
492
+ + "Refusing to continue on a blank page; resume the session to recover safely.",
493
+ {
494
+ code: "CONVERSATION_RESTORE_REQUIRED",
495
+ recoverable: false,
496
+ details: { restorationStatus: restoration?.status ?? null },
497
+ },
498
+ );
499
+ }
500
+ const checkpoint = checkpointIdentity
501
+ ? await checkpointIdentity(restoration)
502
+ : null;
503
+ return { restoration, checkpoint };
504
+ } finally {
505
+ this.deferConversationIdentityPersistence = false;
506
+ }
507
+ }
508
+
509
+ async #freshRebuildBlockers({ instruction = null } = {}) {
510
+ const state = this.session.state;
511
+ const blockers = [];
512
+ if (state.pendingOutbound != null) {
513
+ blockers.push("unconfirmed outbound message");
514
+ }
515
+ if (state.pendingAssistantTurn != null) {
516
+ blockers.push("pending assistant handoff");
517
+ }
518
+ if (state.pendingToolResult != null) {
519
+ blockers.push("pending tool result");
520
+ }
521
+ if (Object.keys(state.completedTools ?? {}).length > 0) {
522
+ blockers.push("completed tool history");
523
+ }
524
+ if (Object.keys(state.sideEffectTools ?? {}).length > 0) {
525
+ blockers.push("side-effect history");
526
+ }
527
+ if (state.lastMessage != null) {
528
+ blockers.push("completed assistant result");
529
+ }
530
+ if (
531
+ state.lastAssistantMessageId != null
532
+ || await this.session.hasEvent("model.message_complete")
533
+ ) {
534
+ blockers.push("completed or ambiguous assistant response");
535
+ }
536
+
537
+ const suppliedInstruction = instruction?.trim() ?? "";
538
+ const followUps = state.followUps ?? [];
539
+ const persistedInstructions = [
540
+ ...new Set(followUps.map((item) => item?.instruction?.trim()).filter(Boolean)),
541
+ ];
542
+ const followUpsHaveAttachments = followUps.some((item) =>
543
+ item?.attachments != null
544
+ && (!Array.isArray(item.attachments) || item.attachments.length > 0)
216
545
  );
546
+ if (
547
+ followUpsHaveAttachments
548
+ || persistedInstructions.length > 1
549
+ || (
550
+ suppliedInstruction
551
+ && persistedInstructions.length === 1
552
+ && persistedInstructions[0] !== suppliedInstruction
553
+ )
554
+ ) {
555
+ blockers.push("earlier follow-up history");
556
+ }
557
+ const rebuildInstruction = suppliedInstruction
558
+ || (persistedInstructions.length === 1 ? persistedInstructions[0] : null);
559
+
560
+ const transcript = await this.session.readTranscript();
561
+ if (transcript.items.length !== 1) {
562
+ blockers.push(
563
+ transcript.items.length === 0
564
+ ? "missing opening transcript"
565
+ : "canonical transcript history",
566
+ );
567
+ } else {
568
+ const item = transcript.items[0].item;
569
+ const hasNoAttachments = item?.attachments == null
570
+ || (Array.isArray(item.attachments) && item.attachments.length === 0);
571
+ const isOriginalUser = item?.type === "message"
572
+ && item.role === "user"
573
+ && messageText(item) === String(state.task ?? "")
574
+ && hasNoAttachments;
575
+ if (!isOriginalUser) {
576
+ blockers.push("canonical transcript history");
577
+ }
578
+ }
579
+ return { blockers, transcript, rebuildInstruction };
217
580
  }
218
581
 
219
582
  buildToolResultMessage(result, { suffix = "" } = {}) {
220
583
  const limit = this.limits.maxBrowserToolResultBytes;
221
- const nonResultBytes = utf8ByteLength(appendSystemReminder(suffix));
584
+ const nonResultBytes = utf8ByteLength(appendSystemReminder(suffix))
585
+ + OUTBOUND_CORRELATION_RESERVE_BYTES;
222
586
  const resultBudget = limit - nonResultBytes;
223
587
  if (resultBudget < 512) {
224
588
  throw new RangeError(
@@ -228,27 +592,280 @@ export class AgentRuntime {
228
592
  return `${serializeToolResult(result, { maxBytes: resultBudget })}${suffix}`;
229
593
  }
230
594
 
231
- async sendToolResult(result, { suffix = "" } = {}) {
232
- await this.sendMessage(this.buildToolResultMessage(result, { suffix }), {
233
- maxBytes: this.limits.maxBrowserToolResultBytes,
595
+ async sendToolResult(result, {
596
+ suffix = "",
597
+ runtimeTurn = null,
598
+ } = {}) {
599
+ return await this.sendMessage(
600
+ this.buildToolResultMessage(result, { suffix }),
601
+ {
602
+ maxBytes: this.limits.maxBrowserToolResultBytes,
603
+ outboundKind: "tool_result",
604
+ runtimeTurn,
605
+ pendingToolAcknowledgement: {
606
+ callId: result.callId ?? null,
607
+ name: result.name ?? null,
608
+ operationSignature: result.operationSignature ?? null,
609
+ },
610
+ },
611
+ );
612
+ }
613
+
614
+ async #appendHandoffTranscript(activeHandoff, suffix, item) {
615
+ if (!activeHandoff?.handoffId) {
616
+ throw new Error("Cannot append transcript without an assistant handoff ID.");
617
+ }
618
+ return await this.session.appendTranscriptItemOnce(item, {
619
+ idempotencyKey: `${activeHandoff.handoffId}:${suffix}`,
234
620
  });
235
621
  }
236
622
 
623
+ #inferPendingRuntimeTurn(pendingOutbound) {
624
+ if (Number.isSafeInteger(pendingOutbound?.runtimeTurn)) {
625
+ return pendingOutbound.runtimeTurn;
626
+ }
627
+ const currentTurn = Number(this.session.state.turn || 0);
628
+ if ([
629
+ "empty_response_recovery",
630
+ "dead_request_recovery",
631
+ "generation_failed_recovery",
632
+ ].includes(pendingOutbound?.kind)) {
633
+ return Math.max(1, currentTurn);
634
+ }
635
+ return currentTurn + 1;
636
+ }
637
+
638
+ #recoveryHandoffPatch(outcome) {
639
+ return {
640
+ conversationUrl: outcome.conversationUrl,
641
+ conversationTargetId: outcome.conversationTargetId,
642
+ userMessageId: outcome.userMessageId,
643
+ userTurn: outcome.userTurn,
644
+ preOutboundMarkerIds: outcome.preOutboundMarkerIds ?? [],
645
+ assistantBaseline: outcome.assistantBaseline ?? null,
646
+ assistantCandidateMessageId:
647
+ outcome.assistantCandidateMessageId ?? null,
648
+ assistantCandidateTurn: outcome.assistantCandidateTurn ?? null,
649
+ };
650
+ }
651
+
652
+ async #refreshHandoffAfterRestore(handoff, restoration) {
653
+ const conversationUrl = restoration?.conversationUrl
654
+ ?? this.session.state.conversationUrl
655
+ ?? handoff.conversationUrl;
656
+ const conversationTargetId = restoration?.targetId
657
+ ?? this.session.state.conversationTargetId
658
+ ?? handoff.conversationTargetId;
659
+ const refreshed = await this.#queueSessionOperation(() => (
660
+ this.session.refreshPendingAssistantTurn(handoff.handoffId, {
661
+ conversationUrl,
662
+ conversationTargetId,
663
+ })
664
+ ));
665
+ this.adapter.acceptRecoveredOutboundAnchor?.({
666
+ conversationUrl,
667
+ conversationTargetId,
668
+ userMessageId: refreshed.userMessageId,
669
+ userTurn: refreshed.userTurn,
670
+ assistantBaseline: refreshed.assistantBaseline,
671
+ assistantCandidateMessageId:
672
+ refreshed.assistantCandidateMessageId ?? null,
673
+ assistantCandidateTurn: refreshed.assistantCandidateTurn ?? null,
674
+ });
675
+ return refreshed;
676
+ }
677
+
678
+ async #recoverPendingConversation({ pendingOutbound, pendingAssistantTurn }) {
679
+ const outboundId = pendingOutbound?.outboundId
680
+ ?? pendingAssistantTurn?.sourceOutboundId
681
+ ?? null;
682
+ if (!isValidOutboundCorrelationId(outboundId)) {
683
+ throw new BrowserAdapterError(
684
+ "The pending browser handoff has no valid outbound correlation ID.",
685
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
686
+ );
687
+ }
688
+ if (!this.adapter.supportsPendingOutboundRecovery?.()) {
689
+ throw new BrowserAdapterError(
690
+ `${this.adapter.providerName ?? "Web provider"} does not support safe pending outbound recovery.`,
691
+ { code: "OUTBOUND_RECOVERY_UNSUPPORTED", recoverable: false },
692
+ );
693
+ }
694
+ const boundTargetId = pendingOutbound?.conversationTargetId
695
+ ?? (!pendingOutbound
696
+ ? pendingAssistantTurn?.conversationTargetId ?? null
697
+ : null);
698
+ const rootTargetId = this.session.state.conversationTargetId ?? null;
699
+ if (
700
+ boundTargetId
701
+ && rootTargetId
702
+ && boundTargetId !== rootTargetId
703
+ ) {
704
+ throw new BrowserAdapterError(
705
+ "The pending browser handoff target conflicts with the Session target. Refusing to choose either target.",
706
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
707
+ );
708
+ }
709
+ const targetId = boundTargetId ?? rootTargetId;
710
+ if (!targetId) {
711
+ throw new BrowserAdapterError(
712
+ "Pending outbound recovery requires the exact saved browser target.",
713
+ { code: "RECOVERY_TARGET_UNAVAILABLE", recoverable: false },
714
+ );
715
+ }
716
+
717
+ await this.adapter.launchRecoveryTarget(targetId);
718
+ await this.emit("browser.started", {
719
+ profileDir: this.adapter.profileDir,
720
+ recovery: true,
721
+ targetId,
722
+ });
723
+ const priorForReconciliation = pendingOutbound
724
+ && pendingAssistantTurn?.sourceOutboundId !== pendingOutbound.outboundId
725
+ ? null
726
+ : pendingAssistantTurn;
727
+ const outcome = await this.adapter.reconcilePendingOutbound({
728
+ pendingOutbound,
729
+ priorHandoff: priorForReconciliation,
730
+ conversationTargetId: targetId,
731
+ lastUserMessageId: this.session.state.lastUserMessageId ?? null,
732
+ lastAssistantMessageId:
733
+ this.session.state.lastAssistantMessageId ?? null,
734
+ });
735
+ if (!["waiting", "complete"].includes(outcome?.status)) {
736
+ throw new BrowserAdapterError(
737
+ "Pending outbound recovery did not return a durable assistant boundary.",
738
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
739
+ );
740
+ }
741
+ const outcomeKind = outcome.conversationUrl
742
+ ? this.adapter.classifyConversationUrl?.(outcome.conversationUrl)
743
+ : null;
744
+ if (
745
+ outcome.conversationTargetId !== targetId
746
+ || !["restorable", "provisional"].includes(outcomeKind)
747
+ ) {
748
+ throw new BrowserAdapterError(
749
+ "Pending outbound recovery returned a different browser target or conversation route.",
750
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
751
+ );
752
+ }
753
+
754
+ let handoff = pendingAssistantTurn;
755
+ if (pendingOutbound) {
756
+ const pendingToolAcknowledgement =
757
+ pendingOutbound.pendingToolAcknowledgement
758
+ ?? (["tool_result", "pending_tool_result"].includes(pendingOutbound.kind)
759
+ && this.session.state.pendingToolResult
760
+ ? {
761
+ callId: this.session.state.pendingToolResult.callId ?? null,
762
+ name: this.session.state.pendingToolResult.name ?? null,
763
+ operationSignature:
764
+ this.session.state.pendingToolResult.operationSignature ?? null,
765
+ }
766
+ : null);
767
+ handoff = await this.#queueSessionOperation(() => (
768
+ this.session.commitPendingOutboundHandoff({
769
+ outboundId: pendingOutbound.outboundId,
770
+ handoff: {
771
+ ...this.#recoveryHandoffPatch(outcome),
772
+ runtimeTurn: this.#inferPendingRuntimeTurn(pendingOutbound),
773
+ pendingToolAcknowledgement,
774
+ },
775
+ })
776
+ ));
777
+ await this.emit("outbound.reconciled", {
778
+ outboundId: pendingOutbound.outboundId,
779
+ kind: pendingOutbound.kind ?? null,
780
+ status: outcome.status,
781
+ userMessageId: outcome.userMessageId ?? null,
782
+ });
783
+ } else if (handoff) {
784
+ handoff = await this.#queueSessionOperation(() => (
785
+ this.session.refreshPendingAssistantTurn(
786
+ handoff.handoffId,
787
+ this.#recoveryHandoffPatch(outcome),
788
+ )
789
+ ));
790
+ }
791
+
792
+ if (outcome.status === "complete") {
793
+ const outcomeHash = createHash("sha256")
794
+ .update(outcome.rawResponse)
795
+ .digest("hex");
796
+ if (outcome.responseHash && outcome.responseHash !== outcomeHash) {
797
+ throw new BrowserAdapterError(
798
+ "Recovered assistant response hash does not match its raw content.",
799
+ { code: "OUTBOUND_COMMIT_UNCERTAIN", recoverable: false },
800
+ );
801
+ }
802
+ handoff = await this.#queueSessionOperation(() => (
803
+ this.session.completePendingAssistantTurn({
804
+ handoffId: handoff.handoffId,
805
+ assistantMessageId: outcome.assistantMessageId ?? null,
806
+ assistantTurn: outcome.assistantTurn ?? null,
807
+ rawResponse: outcome.rawResponse,
808
+ })
809
+ ));
810
+ }
811
+ return handoff;
812
+ }
813
+
237
814
  async run({
238
815
  resume = false,
239
816
  instruction = null,
240
817
  files = [],
241
818
  inPlaceRecovery = false,
242
- mode = null,
243
819
  } = {}) {
244
820
  const {
245
821
  task,
246
822
  projectRoot,
247
- mode: sessionMode,
248
823
  } = this.session.state;
249
824
  const previousConversationUrl = this.session.state.conversationUrl;
250
- await this.session.recoverInterruptedSideEffects();
251
- const pendingToolResult = this.session.state.pendingToolResult;
825
+ const pendingOutboundAtStart = this.session.state.pendingOutbound;
826
+ const pendingAssistantAtStart = this.session.state.pendingAssistantTurn;
827
+ const hasPendingRecovery = pendingOutboundAtStart != null
828
+ || pendingAssistantAtStart != null;
829
+ const supportsStrictRecovery = Boolean(
830
+ this.adapter.supportsPendingOutboundRecovery?.(),
831
+ );
832
+ const strictRecoveryAtStart = pendingOutboundAtStart != null
833
+ || (pendingAssistantAtStart != null && supportsStrictRecovery);
834
+ const legacyAssistantRecoveryAtStart = pendingOutboundAtStart == null
835
+ && pendingAssistantAtStart != null
836
+ && !supportsStrictRecovery;
837
+ if (
838
+ hasPendingRecovery
839
+ && (
840
+ !resume
841
+ || Boolean(instruction?.trim())
842
+ || (files ?? []).length > 0
843
+ )
844
+ ) {
845
+ throw new BrowserAdapterError(
846
+ "A pending browser handoff can only be recovered with a bare resume. "
847
+ + "Do not add an instruction or attachment until recovery completes.",
848
+ { code: "RECOVERY_REQUIRES_BARE_RESUME", recoverable: false },
849
+ );
850
+ }
851
+ const attachments = (files ?? []).map((file) => ({
852
+ name: file.name ?? null,
853
+ path: file.path ?? null,
854
+ }));
855
+ const messageOptions = attachments.length > 0 ? { attachments } : {};
856
+
857
+ // Persist the opening user intent (including attachment metadata) before any
858
+ // browser work. A later recovery can then distinguish a replayable untouched
859
+ // task from an unknowable empty legacy rollout.
860
+ if (!resume) {
861
+ const transcript = await this.session.readTranscript();
862
+ if (transcript.items.length === 0) {
863
+ await this.session.appendTranscriptItemOnce(
864
+ userMessage(task, messageOptions),
865
+ { idempotencyKey: `session:${this.session.sessionId}:opening` },
866
+ );
867
+ }
868
+ }
252
869
 
253
870
  await this.session.update({
254
871
  phase: "initializing",
@@ -257,10 +874,49 @@ export class AgentRuntime {
257
874
  });
258
875
  await this.emit("runtime.initializing");
259
876
 
877
+ const modelTurnTimeoutMs = this.limits.modelTurnTimeoutMs;
878
+ let pendingToolResult;
879
+ let awaitingPendingAcknowledgement = false;
880
+ let replayGuard = null;
881
+ let protocolErrors = 0;
882
+ const baseTurn = resume ? Number(this.session.state.turn || 0) : 0;
883
+
884
+ if (strictRecoveryAtStart) {
885
+ const recoveredHandoff = await this.#recoverPendingConversation({
886
+ pendingOutbound: pendingOutboundAtStart,
887
+ pendingAssistantTurn: pendingAssistantAtStart,
888
+ });
889
+ // Recovery proves the browser boundary before touching a possibly-running
890
+ // side-effect ledger. The existing local exactly-once rules then apply.
891
+ await this.session.recoverInterruptedSideEffects();
892
+ pendingToolResult = this.session.state.pendingToolResult;
893
+ awaitingPendingAcknowledgement = Boolean(
894
+ recoveredHandoff.pendingToolAcknowledgement,
895
+ );
896
+ replayGuard = pendingToolResult?.operationSignature
897
+ ? {
898
+ signature: pendingToolResult.operationSignature,
899
+ result: pendingToolResult,
900
+ }
901
+ : null;
902
+ await this.session.update({
903
+ phase: "running",
904
+ activeMode: null,
905
+ });
906
+ await this.emit("conversation.started", {
907
+ url: recoveredHandoff.conversationUrl,
908
+ recovered: true,
909
+ });
910
+ } else {
260
911
  // On resume, prefer an existing tab already showing the conversation so
261
912
  // repeated resumes reuse the same tab instead of piling up new ones.
262
913
  await this.adapter.launch(
263
914
  resume ? this.session.state.conversationUrl : null,
915
+ {
916
+ preferredTargetId: resume
917
+ ? this.session.state.conversationTargetId ?? null
918
+ : null,
919
+ },
264
920
  );
265
921
  await this.emit("browser.started", {
266
922
  profileDir: this.adapter.profileDir,
@@ -292,80 +948,150 @@ export class AgentRuntime {
292
948
  await this.emit("browser.authenticated");
293
949
  }
294
950
 
295
- await this.adapter.startConversation(
951
+ let restoration = await this.adapter.startConversation(
296
952
  resume ? previousConversationUrl : null,
297
953
  {
298
954
  expectedAssistantMessageId: resume
299
955
  ? this.session.state.lastAssistantMessageId
300
956
  : null,
957
+ expectedUserMessageId: resume
958
+ ? this.session.state.lastUserMessageId
959
+ : null,
301
960
  },
302
961
  );
303
- // A fresh run uses the mode stored at session creation; a resume applies
304
- // an explicit mode override (--mode or the interactive choice) when given,
305
- // and otherwise keeps the mode the conversation is already on.
306
- const requestedMode = mode ?? sessionMode;
307
- // The actual mode may differ from the requested one (Pro limited, fallback,
308
- // or switcher not found), so report what was really selected.
309
- let activeMode = resume
310
- ? (this.session.state.activeMode ?? null)
311
- : null;
312
- const selectRequested = Boolean(
313
- requestedMode && (!resume || mode != null),
314
- );
315
- if (selectRequested) {
316
- const modeResult = await this.adapter.selectMode(requestedMode);
317
- if (modeResult) {
318
- await this.emit("conversation.mode_selected", {
319
- requested: requestedMode,
320
- status: modeResult.status,
321
- selectedLabel: modeResult.selectedLabel ?? null,
322
- attempts: modeResult.attempts ?? 0,
323
- reason: modeResult.reason ?? null,
324
- });
325
- if (modeResult.status === "select" || modeResult.status === "already") {
326
- activeMode = modeResult.selectedLabel ?? requestedMode;
327
- } else if (modeResult.status === "fallback") {
328
- activeMode = modeResult.selectedLabel ?? requestedMode;
329
- } else {
330
- // Pro not selected and no known fallback label — the real mode is
331
- // whatever ChatGPT already had, which we cannot name reliably.
332
- activeMode = null;
333
- }
962
+ if (
963
+ restoration?.status !== "restored-existing"
964
+ && restoration?.status !== "verified-fresh"
965
+ ) {
966
+ throw new BrowserAdapterError(
967
+ `${this.adapter.providerName} did not report whether conversation history was restored.`,
968
+ {
969
+ code: "CONVERSATION_RESTORE_UNVERIFIED",
970
+ recoverable: false,
971
+ },
972
+ );
973
+ }
974
+ if (
975
+ legacyAssistantRecoveryAtStart
976
+ && restoration.status !== "restored-existing"
977
+ ) {
978
+ throw new BrowserAdapterError(
979
+ `${this.adapter.providerName} could not restore the confirmed pending assistant turn. `
980
+ + "Refusing to continue it on a blank conversation.",
981
+ {
982
+ code: "CONVERSATION_RESTORE_REQUIRED",
983
+ recoverable: false,
984
+ },
985
+ );
986
+ }
987
+ await this.#syncConversationIdentity({
988
+ identity: {
989
+ conversationUrl: restoration.conversationUrl,
990
+ targetId: restoration.targetId ?? null,
991
+ kind: restoration.status === "verified-fresh"
992
+ ? "fresh"
993
+ : this.adapter.classifyConversationUrl?.(restoration.conversationUrl),
994
+ },
995
+ allowFresh: restoration.status === "verified-fresh",
996
+ });
997
+
998
+ const resumedExistingConversation = resume
999
+ && restoration.status === "restored-existing";
1000
+ const rebuildingFreshConversation = resume
1001
+ && restoration.status === "verified-fresh";
1002
+ let effectiveInstruction = instruction;
1003
+ if (rebuildingFreshConversation) {
1004
+ const {
1005
+ blockers,
1006
+ rebuildInstruction,
1007
+ } = await this.#freshRebuildBlockers({ instruction });
1008
+ if (blockers.length > 0) {
1009
+ throw new BrowserAdapterError(
1010
+ `${this.adapter.providerName} opened an empty conversation, but this session has `
1011
+ + `history that cannot be replayed safely (${blockers.join(", ")}). `
1012
+ + "The browser message was not sent.",
1013
+ {
1014
+ code: "CONVERSATION_REBUILD_UNSAFE",
1015
+ recoverable: false,
1016
+ details: { blockers },
1017
+ },
1018
+ );
334
1019
  }
1020
+ effectiveInstruction = rebuildInstruction;
1021
+ await this.emit("conversation.rebuilding_fresh", {
1022
+ previousUrl: previousConversationUrl,
1023
+ url: restoration.conversationUrl,
1024
+ });
1025
+ }
1026
+
1027
+ // Only normalize an interrupted side-effect ledger after the browser
1028
+ // restoration decision. An unsafe fresh-page fallback must leave every
1029
+ // pending/result/side-effect record untouched for deliberate inspection.
1030
+ await this.session.recoverInterruptedSideEffects();
1031
+ pendingToolResult = this.session.state.pendingToolResult;
1032
+
1033
+ // Optional setup after authentication and the target conversation are
1034
+ // ready, but before the first message is sent. Interactive CLI runs use
1035
+ // this to let the user choose a model directly on the provider website.
1036
+ // WTAgent never names, selects, or overrides a provider-specific model.
1037
+ // Existing conversations keep their model context. A verified fresh rebuild
1038
+ // is a genuinely new web chat, so interactive runs offer the same browser-side
1039
+ // model choice as an ordinary bootstrap.
1040
+ if (restoration.status === "verified-fresh" && this.postAuthSetup) {
1041
+ await this.postAuthSetup({ adapter: this.adapter });
1042
+ // Browser-side setup is user-controlled and can navigate the tab. Reopen
1043
+ // and re-verify an empty chat after it returns so the bootstrap can never
1044
+ // land in a history conversation selected during setup.
1045
+ restoration = await this.adapter.startConversation();
1046
+ if (restoration?.status !== "verified-fresh") {
1047
+ throw new BrowserAdapterError(
1048
+ `${this.adapter.providerName} did not remain on a verified empty conversation after browser setup.`,
1049
+ {
1050
+ code: "CONVERSATION_NOT_FRESH",
1051
+ recoverable: false,
1052
+ },
1053
+ );
1054
+ }
1055
+ await this.#syncConversationIdentity({
1056
+ identity: {
1057
+ conversationUrl: restoration.conversationUrl,
1058
+ targetId: restoration.targetId ?? null,
1059
+ kind: "fresh",
1060
+ },
1061
+ allowFresh: true,
1062
+ });
335
1063
  }
336
- // ChatGPT Pro can think considerably longer before the first token;
337
- // every other provider/mode uses the default. An explicit
338
- // --model-turn-timeout-ms always wins (resolveLimits marks it). The
339
- // active mode may differ from the requested one (Pro limited, fallback),
340
- // so prefer the mode the conversation is actually on.
341
- const modelTurnTimeoutMs = !this.limits.modelTurnTimeoutExplicit
342
- && (isProMode(activeMode) || isProMode(requestedMode))
343
- ? PRO_MODEL_TURN_TIMEOUT_MS
344
- : this.limits.modelTurnTimeoutMs;
345
1064
 
346
1065
  await this.session.update({
347
1066
  phase: "running",
348
- conversationUrl: await this.adapter.getConversationUrl(),
349
- activeMode,
1067
+ activeMode: null,
350
1068
  });
351
1069
  await this.emit("conversation.started", {
352
1070
  url: this.session.state.conversationUrl,
353
- mode: activeMode,
354
- requestedMode,
355
1071
  });
356
1072
 
1073
+ if (legacyAssistantRecoveryAtStart) {
1074
+ const recoveredHandoff = await this.#refreshHandoffAfterRestore(
1075
+ pendingAssistantAtStart,
1076
+ restoration,
1077
+ );
1078
+ awaitingPendingAcknowledgement = Boolean(
1079
+ recoveredHandoff.pendingToolAcknowledgement,
1080
+ );
1081
+ replayGuard = pendingToolResult?.operationSignature
1082
+ ? {
1083
+ signature: pendingToolResult.operationSignature,
1084
+ result: pendingToolResult,
1085
+ }
1086
+ : null;
1087
+ }
357
1088
  let initialMessage;
358
1089
  let initialKind;
359
1090
  // The web transport gets `.web` (XML/marked text). The portable rollout
360
- // records only the real user message; WTAgent scaffolding stays transport-only.
1091
+ // records only confirmed user messages; WTAgent scaffolding stays transport-only.
361
1092
  let initialTranscript = [];
362
- // @file attachments (if any) accompany the first user message of this run.
363
- const attachments = (files ?? []).map((file) => ({
364
- name: file.name ?? null,
365
- path: file.path ?? null,
366
- }));
367
- const messageOptions = attachments.length > 0 ? { attachments } : {};
368
- if (resume && pendingToolResult && !inPlaceRecovery) {
1093
+ if (!legacyAssistantRecoveryAtStart) {
1094
+ if (resumedExistingConversation && pendingToolResult && !inPlaceRecovery) {
369
1095
  let suffix = "";
370
1096
  if (instruction?.trim()) {
371
1097
  suffix = `\n<resume_instruction>${cdata(instruction)}</resume_instruction>`;
@@ -373,7 +1099,7 @@ export class AgentRuntime {
373
1099
  }
374
1100
  initialMessage = this.buildToolResultMessage(pendingToolResult, { suffix });
375
1101
  initialKind = "pending_tool_result";
376
- } else if (resume && instruction?.trim()) {
1102
+ } else if (resumedExistingConversation && instruction?.trim()) {
377
1103
  // The live web conversation already contains the bootstrap protocol
378
1104
  // and tool catalog. A normal follow-up should be the user's message, not
379
1105
  // another several-thousand-character protocol bootstrap. sendMessage()
@@ -381,7 +1107,7 @@ export class AgentRuntime {
381
1107
  initialMessage = instruction.trim();
382
1108
  initialTranscript = [userMessage(instruction.trim(), messageOptions)];
383
1109
  initialKind = "follow_up";
384
- } else if (resume && inPlaceRecovery) {
1110
+ } else if (resumedExistingConversation && inPlaceRecovery) {
385
1111
  // The original request/tool result is already visible in this live web
386
1112
  // conversation. Ask the provider to continue without duplicating transport
387
1113
  // payloads, attachments, or canonical transcript entries.
@@ -389,13 +1115,21 @@ export class AgentRuntime {
389
1115
  initialKind = "empty_response_recovery";
390
1116
  } else if (resume) {
391
1117
  const prompt = buildResumePrompt({
392
- instruction,
1118
+ instruction: rebuildingFreshConversation
1119
+ ? effectiveInstruction
1120
+ : instruction,
393
1121
  state: this.session.state,
394
1122
  tools: this.registry.list(),
395
1123
  });
396
1124
  initialMessage = prompt.web;
397
- initialTranscript = [userMessage(prompt.user, messageOptions)];
398
- initialKind = "resume";
1125
+ // The original task is already the one safe canonical item required by the
1126
+ // rebuild gate. Record only a newly confirmed follow-up instruction.
1127
+ initialTranscript = rebuildingFreshConversation
1128
+ ? (effectiveInstruction?.trim()
1129
+ ? [userMessage(effectiveInstruction.trim(), messageOptions)]
1130
+ : [])
1131
+ : [userMessage(prompt.user, messageOptions)];
1132
+ initialKind = rebuildingFreshConversation ? "fresh_rebuild" : "resume";
399
1133
  } else {
400
1134
  const prompt = buildBootstrapPrompt({
401
1135
  task,
@@ -403,53 +1137,88 @@ export class AgentRuntime {
403
1137
  tools: this.registry.list(),
404
1138
  });
405
1139
  initialMessage = prompt.web;
406
- initialTranscript = [userMessage(prompt.user, messageOptions)];
1140
+ // The opening item was persisted before browser setup above.
1141
+ initialTranscript = [];
407
1142
  initialKind = "bootstrap";
408
1143
  }
409
-
410
- for (const item of initialTranscript) {
411
- await this.session.appendTranscriptItem(item);
412
1144
  }
413
- await this.sendMessage(initialMessage, {
414
- files,
415
- maxBytes: initialKind === "pending_tool_result"
416
- ? this.limits.maxBrowserToolResultBytes
417
- : null,
418
- });
419
- let awaitingPendingAcknowledgement = Boolean(pendingToolResult);
420
- let replayGuard = pendingToolResult?.operationSignature
421
- ? {
422
- signature: pendingToolResult.operationSignature,
423
- result: pendingToolResult,
424
- }
425
- : null;
426
- await this.emit("model.message_sent", { kind: initialKind });
427
1145
 
428
- let protocolErrors = 0;
429
- const baseTurn = resume ? Number(this.session.state.turn || 0) : 0;
1146
+ if (!legacyAssistantRecoveryAtStart) {
1147
+ await this.sendMessage(initialMessage, {
1148
+ files,
1149
+ maxBytes: initialKind === "pending_tool_result"
1150
+ ? this.limits.maxBrowserToolResultBytes
1151
+ : null,
1152
+ // A full bootstrap/rebuild prompt may reconnect only when submission was
1153
+ // proven not to have started. Context-dependent messages never opt in.
1154
+ allowFreshReconnect: restoration.status === "verified-fresh",
1155
+ allowAssistantContinuation: initialKind === "empty_response_recovery",
1156
+ outboundKind: initialKind,
1157
+ transcriptItems: initialTranscript,
1158
+ runtimeTurn: baseTurn + 1,
1159
+ pendingToolAcknowledgement: pendingToolResult
1160
+ ? {
1161
+ callId: pendingToolResult.callId ?? null,
1162
+ name: pendingToolResult.name ?? null,
1163
+ operationSignature: pendingToolResult.operationSignature ?? null,
1164
+ }
1165
+ : null,
1166
+ });
1167
+ awaitingPendingAcknowledgement = Boolean(pendingToolResult);
1168
+ replayGuard = pendingToolResult?.operationSignature
1169
+ ? {
1170
+ signature: pendingToolResult.operationSignature,
1171
+ result: pendingToolResult,
1172
+ }
1173
+ : null;
1174
+ await this.emit("model.message_sent", { kind: initialKind });
1175
+ }
1176
+ }
430
1177
 
431
1178
  for (let step = 1; ; step += 1) {
432
- const turnNumber = baseTurn + step;
1179
+ let activeHandoff = this.session.state.pendingAssistantTurn;
1180
+ if (!activeHandoff) {
1181
+ throw new BrowserAdapterError(
1182
+ "The durable assistant handoff disappeared before response processing.",
1183
+ { code: "ASSISTANT_HANDOFF_MISSING", recoverable: false },
1184
+ );
1185
+ }
1186
+ const turnNumber = Number.isSafeInteger(activeHandoff.runtimeTurn)
1187
+ ? activeHandoff.runtimeTurn
1188
+ : baseTurn + step;
433
1189
  await this.session.update({ turn: turnNumber, phase: "waiting_model" });
434
1190
  let raw;
435
1191
  let emptyAssistantRetries = 0;
436
1192
  let connectionRetries = 0;
437
1193
  for (;;) {
438
1194
  try {
439
- raw = await this.adapter.waitForTurnComplete({
440
- timeoutMs: modelTurnTimeoutMs,
441
- stableWindowMs: this.limits.modelStableWindowMs,
442
- emptyResponseWindowMs: this.limits.emptyAssistantWindowMs,
443
- deadRequestGraceMs: this.limits.deadRequestGraceMs,
444
- onDelta: async (delta) => {
445
- await this.onEvent?.({
446
- type: "model.streaming",
447
- sessionId: this.session.sessionId,
448
- timestamp: new Date().toISOString(),
449
- payload: { delta },
1195
+ if (activeHandoff.status === "complete") {
1196
+ raw = activeHandoff.rawResponse;
1197
+ } else {
1198
+ try {
1199
+ raw = await this.adapter.waitForTurnComplete({
1200
+ timeoutMs: modelTurnTimeoutMs,
1201
+ stableWindowMs: this.limits.modelStableWindowMs,
1202
+ emptyResponseWindowMs: this.limits.emptyAssistantWindowMs,
1203
+ deadRequestGraceMs: this.limits.deadRequestGraceMs,
1204
+ onDelta: async (delta) => {
1205
+ await this.onEvent?.({
1206
+ type: "model.streaming",
1207
+ sessionId: this.session.sessionId,
1208
+ timestamp: new Date().toISOString(),
1209
+ payload: { delta },
1210
+ });
1211
+ },
450
1212
  });
451
- },
452
- });
1213
+ } finally {
1214
+ // Navigation events normally persist canonicalization immediately;
1215
+ // this validated fallback runs on every outcome but never masks a
1216
+ // completed reply or the original browser/model error.
1217
+ await this.#syncConversationIdentity({
1218
+ suppressErrors: true,
1219
+ });
1220
+ }
1221
+ }
453
1222
  break;
454
1223
  } catch (error) {
455
1224
  if (isConnectionLostError(error)) {
@@ -457,9 +1226,25 @@ export class AgentRuntime {
457
1226
  throw error;
458
1227
  }
459
1228
  connectionRetries += 1;
460
- await this.#reconnectAndRestore();
1229
+ if (supportsStrictRecovery) {
1230
+ await this.adapter.detach?.();
1231
+ activeHandoff = await this.#recoverPendingConversation({
1232
+ pendingOutbound: null,
1233
+ pendingAssistantTurn: activeHandoff,
1234
+ });
1235
+ } else {
1236
+ const { checkpoint } = await this.#reconnectAndRestore({
1237
+ checkpointIdentity: (restoration) => (
1238
+ this.#refreshHandoffAfterRestore(
1239
+ activeHandoff,
1240
+ restoration,
1241
+ )
1242
+ ),
1243
+ });
1244
+ activeHandoff = checkpoint;
1245
+ }
461
1246
  // The message was already sent before the connection died; resume
462
- // waiting for the reply on the restored page.
1247
+ // waiting for the reply on the restored exact conversation boundary.
463
1248
  continue;
464
1249
  }
465
1250
 
@@ -485,7 +1270,6 @@ export class AgentRuntime {
485
1270
  const emptyAssistantMessageId = await this.adapter
486
1271
  .getLastAssistantMessageId?.() ?? null;
487
1272
  await this.session.update({
488
- conversationUrl: await this.adapter.getConversationUrl(),
489
1273
  lastAssistantMessageId: emptyAssistantMessageId
490
1274
  ?? this.session.state.lastAssistantMessageId,
491
1275
  });
@@ -525,13 +1309,25 @@ export class AgentRuntime {
525
1309
  // Do not resend the original request or tool result: both are already
526
1310
  // present in ChatGPT's conversation. This transport-only continuation
527
1311
  // also cannot re-execute a local tool by itself.
528
- await this.sendMessage(
1312
+ const continuationSend = await this.sendMessage(
529
1313
  deadRequest
530
1314
  ? DEAD_REQUEST_CONTINUE_MESSAGE
531
1315
  : generationFailed
532
1316
  ? GENERATION_FAILED_CONTINUE_MESSAGE
533
1317
  : EMPTY_ASSISTANT_CONTINUE_MESSAGE,
1318
+ {
1319
+ allowAssistantContinuation: true,
1320
+ outboundKind: deadRequest
1321
+ ? "dead_request_recovery"
1322
+ : generationFailed
1323
+ ? "generation_failed_recovery"
1324
+ : "empty_response_recovery",
1325
+ runtimeTurn: turnNumber,
1326
+ pendingToolAcknowledgement:
1327
+ activeHandoff.pendingToolAcknowledgement ?? null,
1328
+ },
534
1329
  );
1330
+ activeHandoff = continuationSend.pendingAssistantTurn;
535
1331
  await this.emit("model.message_sent", {
536
1332
  kind: deadRequest
537
1333
  ? "dead_request_recovery"
@@ -542,18 +1338,34 @@ export class AgentRuntime {
542
1338
  });
543
1339
  }
544
1340
  }
545
- const assistantMessageId = await this.adapter
546
- .getLastAssistantMessageId?.() ?? null;
1341
+ const assistantMessageId = activeHandoff.status === "complete"
1342
+ ? activeHandoff.assistantMessageId ?? null
1343
+ : await this.adapter.getLastAssistantMessageId?.() ?? null;
1344
+ const assistantTurn = activeHandoff.status === "complete"
1345
+ ? activeHandoff.assistantTurn ?? null
1346
+ : await this.adapter.getLastAssistantTurn?.() ?? null;
1347
+ if (activeHandoff.status === "waiting") {
1348
+ activeHandoff = await this.#queueSessionOperation(() => (
1349
+ this.session.completePendingAssistantTurn({
1350
+ handoffId: activeHandoff.handoffId,
1351
+ assistantMessageId,
1352
+ assistantTurn,
1353
+ rawResponse: raw,
1354
+ })
1355
+ ));
1356
+ } else {
1357
+ const rawHash = createHash("sha256").update(raw).digest("hex");
1358
+ if (rawHash !== activeHandoff.responseHash) {
1359
+ throw new BrowserAdapterError(
1360
+ "The durable assistant response changed before processing.",
1361
+ { code: "ASSISTANT_HANDOFF_COLLISION", recoverable: false },
1362
+ );
1363
+ }
1364
+ }
547
1365
  if (awaitingPendingAcknowledgement) {
548
- await this.session.clearPendingToolResult();
549
- awaitingPendingAcknowledgement = false;
1366
+ await this.#queueSessionUpdate({ pendingToolResult: null });
550
1367
  }
551
- await this.session.update({
552
- conversationUrl: await this.adapter.getConversationUrl(),
553
- // Null is meaningful: retaining an older ID would falsely prove only
554
- // that stale history had hydrated on the next resume.
555
- lastAssistantMessageId: assistantMessageId,
556
- });
1368
+ awaitingPendingAcknowledgement = false;
557
1369
  await this.emit("model.message_complete", {
558
1370
  turn: turnNumber,
559
1371
  raw,
@@ -561,8 +1373,34 @@ export class AgentRuntime {
561
1373
  });
562
1374
 
563
1375
  let parsed;
1376
+ let finalMessage;
1377
+ let usedTrailingProse = false;
564
1378
  try {
565
1379
  parsed = parseAgentResponse(raw);
1380
+ finalMessage = parsed.message;
1381
+ // Some providers put the deliverable after a done=true envelope. Merge
1382
+ // that display text before validating the final answer.
1383
+ if (parsed.done) {
1384
+ const trailing = extractTrailingProse(raw);
1385
+ if (trailing) {
1386
+ finalMessage = [parsed.message.trim(), trailing]
1387
+ .filter(Boolean)
1388
+ .join("\n\n");
1389
+ usedTrailingProse = true;
1390
+ }
1391
+ if (!finalMessage.trim()) {
1392
+ throw new ProtocolError("done=true requires a non-empty final message.");
1393
+ }
1394
+ if (
1395
+ !parsed.toolCall
1396
+ && /<tool_calls[\s>]|<tool_call[\s>]|<invoke[\s>]/i.test(finalMessage)
1397
+ ) {
1398
+ throw new ProtocolError(
1399
+ "Tool calls must use the <tool_call> element, not the <message> text.",
1400
+ );
1401
+ }
1402
+ }
1403
+ // Syntax and semantic errors share one consecutive-retry budget.
566
1404
  protocolErrors = 0;
567
1405
  } catch (error) {
568
1406
  if (!(error instanceof ProtocolError)) {
@@ -602,12 +1440,21 @@ export class AgentRuntime {
602
1440
  await this.emit("protocol.plain_answer", {
603
1441
  snippet: plainAnswer.slice(0, 200),
604
1442
  });
605
- await this.session.update({
606
- phase: "idle",
607
- lastMessage: plainAnswer,
608
- pendingToolResult: null,
609
- });
610
- await this.session.appendTranscriptItem(assistantMessage(plainAnswer));
1443
+ await this.#appendHandoffTranscript(
1444
+ activeHandoff,
1445
+ "assistant",
1446
+ assistantMessage(plainAnswer),
1447
+ );
1448
+ await this.#queueSessionOperation(() => (
1449
+ this.session.clearPendingAssistantTurn(
1450
+ activeHandoff.handoffId,
1451
+ {
1452
+ phase: "idle",
1453
+ lastMessage: plainAnswer,
1454
+ pendingToolResult: null,
1455
+ },
1456
+ )
1457
+ ));
611
1458
  await this.emit("run.completed", {
612
1459
  message: plainAnswer,
613
1460
  plainAnswer: true,
@@ -628,79 +1475,39 @@ export class AgentRuntime {
628
1475
  `Protocol failed ${protocolErrors} consecutive times: ${error.message}`,
629
1476
  );
630
1477
  }
631
- await this.sendMessage(serializeProtocolError(error));
1478
+ await this.sendMessage(serializeProtocolError(error), {
1479
+ allowAssistantContinuation: true,
1480
+ outboundKind: "protocol_correction",
1481
+ runtimeTurn: turnNumber + 1,
1482
+ });
632
1483
  continue;
633
1484
  }
634
1485
 
635
- // When the run finishes, some models (GLM especially) put their real
636
- // deliverable AFTER the envelope: a short done/true stub followed by the
637
- // full markdown/HTML answer. That trailing prose is pure display content
638
- // (it cannot trigger a tool), so merge it into the final message instead
639
- // of dropping it. Non-done turns keep the protocol message untouched.
640
- let finalMessage = parsed.message;
641
- let usedTrailingProse = false;
642
- if (parsed.done) {
643
- const trailing = extractTrailingProse(raw);
644
- if (trailing) {
645
- finalMessage = [parsed.message.trim(), trailing]
646
- .filter(Boolean)
647
- .join("\n\n");
648
- usedTrailingProse = true;
649
- }
650
- }
651
-
652
1486
  // Record the assistant's turn in the canonical transcript. The raw XML is
653
1487
  // the web rendering; the transcript keeps the plain progress message.
654
1488
  if (finalMessage?.trim()) {
655
- await this.session.appendTranscriptItem(
1489
+ await this.#appendHandoffTranscript(
1490
+ activeHandoff,
1491
+ "assistant",
656
1492
  assistantMessage(finalMessage),
657
1493
  );
658
1494
  }
659
1495
 
660
1496
  if (parsed.done) {
661
- if (!finalMessage.trim()) {
662
- const error = new ProtocolError(
663
- "done=true requires a non-empty final message.",
664
- );
665
- await this.emit("protocol.invalid", { message: error.message });
666
- await this.sendMessage(serializeProtocolError(error));
667
- continue;
668
- }
669
- // Some models (DeepSeek especially) write their tool request inside
670
- // the <message> of a done=true envelope instead of a <tool_call>
671
- // element. Completing there would swallow the tool call and end the
672
- // run with raw XML as the "answer". Treat it as a format slip: the
673
- // protocol-error feedback tells the model where tool calls go, and
674
- // the normal retry limit still applies.
675
- if (
676
- !parsed.toolCall
677
- && /<tool_calls[\s>]|<tool_call[\s>]|<invoke[\s>]/i.test(finalMessage)
678
- ) {
679
- const error = new ProtocolError(
680
- "Tool calls must use the <tool_call> element, not the <message> text.",
681
- );
682
- protocolErrors += 1;
683
- await this.emit("protocol.invalid", {
684
- message: error.message,
685
- count: protocolErrors,
686
- });
687
- if (protocolErrors >= this.limits.maxProtocolErrors) {
688
- throw new ProtocolError(
689
- `Protocol failed ${protocolErrors} consecutive times: ${error.message}`,
690
- );
691
- }
692
- await this.sendMessage(serializeProtocolError(error));
693
- continue;
694
- }
695
1497
  // done=true completes the run. A request may be answered directly
696
1498
  // (no tool call) or after any number of tools; the runtime does not
697
1499
  // second-guess whether "enough" work happened — that is the model's
698
1500
  // and the user's call, not a keyword heuristic.
699
- await this.session.update({
700
- phase: "idle",
701
- lastMessage: finalMessage,
702
- pendingToolResult: null,
703
- });
1501
+ await this.#queueSessionOperation(() => (
1502
+ this.session.clearPendingAssistantTurn(
1503
+ activeHandoff.handoffId,
1504
+ {
1505
+ phase: "idle",
1506
+ lastMessage: finalMessage,
1507
+ pendingToolResult: null,
1508
+ },
1509
+ )
1510
+ ));
704
1511
  await this.emit("run.completed", {
705
1512
  message: finalMessage,
706
1513
  usedTrailingProse,
@@ -729,6 +1536,11 @@ export class AgentRuntime {
729
1536
  + "If you need to take action on the user's machine, request one local tool. "
730
1537
  + "If you need information from the user, ask one specific question in <message> "
731
1538
  + "and set <done>true</done> so control returns to the user.",
1539
+ {
1540
+ allowAssistantContinuation: true,
1541
+ outboundKind: "progress_correction",
1542
+ runtimeTurn: turnNumber + 1,
1543
+ },
732
1544
  );
733
1545
  continue;
734
1546
  }
@@ -771,16 +1583,24 @@ export class AgentRuntime {
771
1583
  await this.onEvent?.(completionEvent);
772
1584
  // Record the rejected call and its error output so the transcript stays
773
1585
  // a faithful, replay-free record of what the model attempted.
774
- await this.session.appendTranscriptItem(functionCall({
775
- name: normalizedCall.name,
776
- args: normalizedCall.args,
777
- callId: normalizedCall.id,
778
- }));
779
- await this.session.appendTranscriptItem(functionCallOutput({
780
- callId: result.callId,
781
- output: toolResultOutput(result),
782
- }));
783
- await this.sendToolResult(result);
1586
+ await this.#appendHandoffTranscript(
1587
+ activeHandoff,
1588
+ "function_call",
1589
+ functionCall({
1590
+ name: normalizedCall.name,
1591
+ args: normalizedCall.args,
1592
+ callId: normalizedCall.id,
1593
+ }),
1594
+ );
1595
+ await this.#appendHandoffTranscript(
1596
+ activeHandoff,
1597
+ "function_output",
1598
+ functionCallOutput({
1599
+ callId: result.callId,
1600
+ output: toolResultOutput(result),
1601
+ }),
1602
+ );
1603
+ await this.sendToolResult(result, { runtimeTurn: turnNumber + 1 });
784
1604
  awaitingPendingAcknowledgement = true;
785
1605
  await this.emit("tool.result_sent", {
786
1606
  id: result.callId,
@@ -794,11 +1614,15 @@ export class AgentRuntime {
794
1614
  name: preparedCall.name,
795
1615
  args: preparedCall.args,
796
1616
  });
797
- await this.session.appendTranscriptItem(functionCall({
798
- name: preparedCall.name,
799
- args: preparedCall.args,
800
- callId: preparedCall.id,
801
- }));
1617
+ await this.#appendHandoffTranscript(
1618
+ activeHandoff,
1619
+ "function_call",
1620
+ functionCall({
1621
+ name: preparedCall.name,
1622
+ args: preparedCall.args,
1623
+ callId: preparedCall.id,
1624
+ }),
1625
+ );
802
1626
 
803
1627
  const isReadTool = preparedCall.definition.risk === "read";
804
1628
  const sideEffect = isReadTool ? null : identity;
@@ -970,11 +1794,15 @@ export class AgentRuntime {
970
1794
  }
971
1795
  }
972
1796
 
973
- await this.session.appendTranscriptItem(functionCallOutput({
974
- callId: result.callId,
975
- output: toolResultOutput(result),
976
- }));
977
- await this.sendToolResult(result);
1797
+ await this.#appendHandoffTranscript(
1798
+ activeHandoff,
1799
+ "function_output",
1800
+ functionCallOutput({
1801
+ callId: result.callId,
1802
+ output: toolResultOutput(result),
1803
+ }),
1804
+ );
1805
+ await this.sendToolResult(result, { runtimeTurn: turnNumber + 1 });
978
1806
  awaitingPendingAcknowledgement = true;
979
1807
  await this.emit("tool.result_sent", {
980
1808
  id: result.callId,