wtagent 0.3.0 → 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,6 +19,7 @@ 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";
@@ -26,7 +30,10 @@ import {
26
30
  ProtocolError,
27
31
  ToolValidationError,
28
32
  } from "../shared/errors.js";
29
- import { isConnectionLostError } from "../browser/base-web-adapter.js";
33
+ import {
34
+ isConnectionLostError,
35
+ isValidOutboundCorrelationId,
36
+ } from "../browser/base-web-adapter.js";
30
37
  import { isUsageLimitNotice } from "../shared/usage-limit.js";
31
38
 
32
39
  const EMPTY_ASSISTANT_CONTINUE_MESSAGE =
@@ -44,6 +51,11 @@ const GENERATION_FAILED_CONTINUE_MESSAGE =
44
51
  + "Retry the immediately preceding task from the existing conversation context. Do not "
45
52
  + "repeat any local tool operation whose result is already present. Reply using the "
46
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
+ );
47
59
 
48
60
  function canonicalize(value) {
49
61
  if (Array.isArray(value)) {
@@ -155,6 +167,11 @@ export class AgentRuntime {
155
167
  this.onEvent = onEvent;
156
168
  this.postAuthSetup = postAuthSetup;
157
169
  this.limits = limits;
170
+ this.conversationIdentityQueue = Promise.resolve();
171
+ this.deferConversationIdentityPersistence = false;
172
+ this.adapter.setConversationIdentityListener?.((identity) =>
173
+ this.#queueConversationIdentity(identity)
174
+ );
158
175
  }
159
176
 
160
177
  async emit(type, payload = {}) {
@@ -163,60 +180,409 @@ export class AgentRuntime {
163
180
  return event;
164
181
  }
165
182
 
166
- async sendMessage(text, { files = [], maxBytes = null } = {}) {
167
- 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 });
168
210
  try {
169
- 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;
170
252
  } finally {
171
- 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)
172
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;
173
349
  if (
174
- conversationUrl
175
- && conversationUrl !== this.session.state.conversationUrl
350
+ conversationUrl === current.conversationUrl
351
+ && conversationTargetId === current.conversationTargetId
176
352
  ) {
177
- await this.session.update({ conversationUrl });
353
+ return structuredClone(current);
178
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;
179
406
  }
180
407
  }
181
408
 
182
- // Sends once; if the browser connection died in between (e.g. the Mac slept
183
- // while a tool was running), reconnects to the still-alive Chrome, restores
184
- // the conversation, and retries once. A send that never rendered (ChatGPT
185
- // did not register the message) is also retried once — the deterministic
186
- // message is simply sent again. Anything else propagates.
187
- 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
+ }) {
188
419
  try {
189
- await this.adapter.sendMessage(message, { files, maxBytes });
420
+ return await this.adapter.sendMessage(message, {
421
+ files,
422
+ maxBytes,
423
+ outboundId,
424
+ allowAssistantContinuation,
425
+ });
190
426
  } catch (error) {
191
427
  if (isConnectionLostError(error)) {
192
- await this.#reconnectAndRestore();
193
- await this.adapter.sendMessage(message, { files, maxBytes });
194
- return;
195
- }
196
- if (error?.code === "SEND_NOT_DETECTED") {
197
- await this.adapter.sendMessage(message, { files, maxBytes });
198
- 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
+ });
199
456
  }
200
457
  throw error;
201
458
  }
202
459
  }
203
460
 
204
- // Reconnects to the still-alive Chrome and navigates back to the session's
205
- // conversation, so the DOM state needed by the adapter is restored.
206
- async #reconnectAndRestore() {
207
- await this.adapter.reconnect?.(this.session.state.conversationUrl);
208
- await this.adapter.startConversation(
209
- this.session.state.conversationUrl,
210
- {
211
- expectedAssistantMessageId: this.session.state.lastAssistantMessageId
212
- ?? null,
213
- },
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)
214
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 };
215
580
  }
216
581
 
217
582
  buildToolResultMessage(result, { suffix = "" } = {}) {
218
583
  const limit = this.limits.maxBrowserToolResultBytes;
219
- const nonResultBytes = utf8ByteLength(appendSystemReminder(suffix));
584
+ const nonResultBytes = utf8ByteLength(appendSystemReminder(suffix))
585
+ + OUTBOUND_CORRELATION_RESERVE_BYTES;
220
586
  const resultBudget = limit - nonResultBytes;
221
587
  if (resultBudget < 512) {
222
588
  throw new RangeError(
@@ -226,10 +592,223 @@ export class AgentRuntime {
226
592
  return `${serializeToolResult(result, { maxBytes: resultBudget })}${suffix}`;
227
593
  }
228
594
 
229
- async sendToolResult(result, { suffix = "" } = {}) {
230
- await this.sendMessage(this.buildToolResultMessage(result, { suffix }), {
231
- 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}`,
620
+ });
621
+ }
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,
232
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;
233
812
  }
234
813
 
235
814
  async run({
@@ -243,8 +822,50 @@ export class AgentRuntime {
243
822
  projectRoot,
244
823
  } = this.session.state;
245
824
  const previousConversationUrl = this.session.state.conversationUrl;
246
- await this.session.recoverInterruptedSideEffects();
247
- 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
+ }
248
869
 
249
870
  await this.session.update({
250
871
  phase: "initializing",
@@ -253,10 +874,49 @@ export class AgentRuntime {
253
874
  });
254
875
  await this.emit("runtime.initializing");
255
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 {
256
911
  // On resume, prefer an existing tab already showing the conversation so
257
912
  // repeated resumes reuse the same tab instead of piling up new ones.
258
913
  await this.adapter.launch(
259
914
  resume ? this.session.state.conversationUrl : null,
915
+ {
916
+ preferredTargetId: resume
917
+ ? this.session.state.conversationTargetId ?? null
918
+ : null,
919
+ },
260
920
  );
261
921
  await this.emit("browser.started", {
262
922
  profileDir: this.adapter.profileDir,
@@ -288,47 +948,150 @@ export class AgentRuntime {
288
948
  await this.emit("browser.authenticated");
289
949
  }
290
950
 
291
- await this.adapter.startConversation(
951
+ let restoration = await this.adapter.startConversation(
292
952
  resume ? previousConversationUrl : null,
293
953
  {
294
954
  expectedAssistantMessageId: resume
295
955
  ? this.session.state.lastAssistantMessageId
296
956
  : null,
957
+ expectedUserMessageId: resume
958
+ ? this.session.state.lastUserMessageId
959
+ : null,
297
960
  },
298
961
  );
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
+ );
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;
299
1032
 
300
1033
  // Optional setup after authentication and the target conversation are
301
1034
  // ready, but before the first message is sent. Interactive CLI runs use
302
1035
  // this to let the user choose a model directly on the provider website.
303
1036
  // WTAgent never names, selects, or overrides a provider-specific model.
304
- // Resumes skip this setup so an existing conversation is not interrupted.
305
- if (!resume && this.postAuthSetup) {
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) {
306
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
+ });
307
1063
  }
308
1064
 
309
- const modelTurnTimeoutMs = this.limits.modelTurnTimeoutMs;
310
-
311
1065
  await this.session.update({
312
1066
  phase: "running",
313
- conversationUrl: await this.adapter.getConversationUrl(),
314
1067
  activeMode: null,
315
1068
  });
316
1069
  await this.emit("conversation.started", {
317
1070
  url: this.session.state.conversationUrl,
318
1071
  });
319
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
+ }
320
1088
  let initialMessage;
321
1089
  let initialKind;
322
1090
  // The web transport gets `.web` (XML/marked text). The portable rollout
323
- // records only the real user message; WTAgent scaffolding stays transport-only.
1091
+ // records only confirmed user messages; WTAgent scaffolding stays transport-only.
324
1092
  let initialTranscript = [];
325
- // @file attachments (if any) accompany the first user message of this run.
326
- const attachments = (files ?? []).map((file) => ({
327
- name: file.name ?? null,
328
- path: file.path ?? null,
329
- }));
330
- const messageOptions = attachments.length > 0 ? { attachments } : {};
331
- if (resume && pendingToolResult && !inPlaceRecovery) {
1093
+ if (!legacyAssistantRecoveryAtStart) {
1094
+ if (resumedExistingConversation && pendingToolResult && !inPlaceRecovery) {
332
1095
  let suffix = "";
333
1096
  if (instruction?.trim()) {
334
1097
  suffix = `\n<resume_instruction>${cdata(instruction)}</resume_instruction>`;
@@ -336,7 +1099,7 @@ export class AgentRuntime {
336
1099
  }
337
1100
  initialMessage = this.buildToolResultMessage(pendingToolResult, { suffix });
338
1101
  initialKind = "pending_tool_result";
339
- } else if (resume && instruction?.trim()) {
1102
+ } else if (resumedExistingConversation && instruction?.trim()) {
340
1103
  // The live web conversation already contains the bootstrap protocol
341
1104
  // and tool catalog. A normal follow-up should be the user's message, not
342
1105
  // another several-thousand-character protocol bootstrap. sendMessage()
@@ -344,7 +1107,7 @@ export class AgentRuntime {
344
1107
  initialMessage = instruction.trim();
345
1108
  initialTranscript = [userMessage(instruction.trim(), messageOptions)];
346
1109
  initialKind = "follow_up";
347
- } else if (resume && inPlaceRecovery) {
1110
+ } else if (resumedExistingConversation && inPlaceRecovery) {
348
1111
  // The original request/tool result is already visible in this live web
349
1112
  // conversation. Ask the provider to continue without duplicating transport
350
1113
  // payloads, attachments, or canonical transcript entries.
@@ -352,13 +1115,21 @@ export class AgentRuntime {
352
1115
  initialKind = "empty_response_recovery";
353
1116
  } else if (resume) {
354
1117
  const prompt = buildResumePrompt({
355
- instruction,
1118
+ instruction: rebuildingFreshConversation
1119
+ ? effectiveInstruction
1120
+ : instruction,
356
1121
  state: this.session.state,
357
1122
  tools: this.registry.list(),
358
1123
  });
359
1124
  initialMessage = prompt.web;
360
- initialTranscript = [userMessage(prompt.user, messageOptions)];
361
- 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";
362
1133
  } else {
363
1134
  const prompt = buildBootstrapPrompt({
364
1135
  task,
@@ -366,53 +1137,88 @@ export class AgentRuntime {
366
1137
  tools: this.registry.list(),
367
1138
  });
368
1139
  initialMessage = prompt.web;
369
- initialTranscript = [userMessage(prompt.user, messageOptions)];
1140
+ // The opening item was persisted before browser setup above.
1141
+ initialTranscript = [];
370
1142
  initialKind = "bootstrap";
371
1143
  }
372
-
373
- for (const item of initialTranscript) {
374
- await this.session.appendTranscriptItem(item);
375
1144
  }
376
- await this.sendMessage(initialMessage, {
377
- files,
378
- maxBytes: initialKind === "pending_tool_result"
379
- ? this.limits.maxBrowserToolResultBytes
380
- : null,
381
- });
382
- let awaitingPendingAcknowledgement = Boolean(pendingToolResult);
383
- let replayGuard = pendingToolResult?.operationSignature
384
- ? {
385
- signature: pendingToolResult.operationSignature,
386
- result: pendingToolResult,
387
- }
388
- : null;
389
- await this.emit("model.message_sent", { kind: initialKind });
390
1145
 
391
- let protocolErrors = 0;
392
- 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
+ }
393
1177
 
394
1178
  for (let step = 1; ; step += 1) {
395
- 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;
396
1189
  await this.session.update({ turn: turnNumber, phase: "waiting_model" });
397
1190
  let raw;
398
1191
  let emptyAssistantRetries = 0;
399
1192
  let connectionRetries = 0;
400
1193
  for (;;) {
401
1194
  try {
402
- raw = await this.adapter.waitForTurnComplete({
403
- timeoutMs: modelTurnTimeoutMs,
404
- stableWindowMs: this.limits.modelStableWindowMs,
405
- emptyResponseWindowMs: this.limits.emptyAssistantWindowMs,
406
- deadRequestGraceMs: this.limits.deadRequestGraceMs,
407
- onDelta: async (delta) => {
408
- await this.onEvent?.({
409
- type: "model.streaming",
410
- sessionId: this.session.sessionId,
411
- timestamp: new Date().toISOString(),
412
- 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
+ },
413
1212
  });
414
- },
415
- });
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
+ }
416
1222
  break;
417
1223
  } catch (error) {
418
1224
  if (isConnectionLostError(error)) {
@@ -420,9 +1226,25 @@ export class AgentRuntime {
420
1226
  throw error;
421
1227
  }
422
1228
  connectionRetries += 1;
423
- 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
+ }
424
1246
  // The message was already sent before the connection died; resume
425
- // waiting for the reply on the restored page.
1247
+ // waiting for the reply on the restored exact conversation boundary.
426
1248
  continue;
427
1249
  }
428
1250
 
@@ -448,7 +1270,6 @@ export class AgentRuntime {
448
1270
  const emptyAssistantMessageId = await this.adapter
449
1271
  .getLastAssistantMessageId?.() ?? null;
450
1272
  await this.session.update({
451
- conversationUrl: await this.adapter.getConversationUrl(),
452
1273
  lastAssistantMessageId: emptyAssistantMessageId
453
1274
  ?? this.session.state.lastAssistantMessageId,
454
1275
  });
@@ -488,13 +1309,25 @@ export class AgentRuntime {
488
1309
  // Do not resend the original request or tool result: both are already
489
1310
  // present in ChatGPT's conversation. This transport-only continuation
490
1311
  // also cannot re-execute a local tool by itself.
491
- await this.sendMessage(
1312
+ const continuationSend = await this.sendMessage(
492
1313
  deadRequest
493
1314
  ? DEAD_REQUEST_CONTINUE_MESSAGE
494
1315
  : generationFailed
495
1316
  ? GENERATION_FAILED_CONTINUE_MESSAGE
496
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
+ },
497
1329
  );
1330
+ activeHandoff = continuationSend.pendingAssistantTurn;
498
1331
  await this.emit("model.message_sent", {
499
1332
  kind: deadRequest
500
1333
  ? "dead_request_recovery"
@@ -505,18 +1338,34 @@ export class AgentRuntime {
505
1338
  });
506
1339
  }
507
1340
  }
508
- const assistantMessageId = await this.adapter
509
- .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
+ }
510
1365
  if (awaitingPendingAcknowledgement) {
511
- await this.session.clearPendingToolResult();
512
- awaitingPendingAcknowledgement = false;
1366
+ await this.#queueSessionUpdate({ pendingToolResult: null });
513
1367
  }
514
- await this.session.update({
515
- conversationUrl: await this.adapter.getConversationUrl(),
516
- // Null is meaningful: retaining an older ID would falsely prove only
517
- // that stale history had hydrated on the next resume.
518
- lastAssistantMessageId: assistantMessageId,
519
- });
1368
+ awaitingPendingAcknowledgement = false;
520
1369
  await this.emit("model.message_complete", {
521
1370
  turn: turnNumber,
522
1371
  raw,
@@ -524,8 +1373,34 @@ export class AgentRuntime {
524
1373
  });
525
1374
 
526
1375
  let parsed;
1376
+ let finalMessage;
1377
+ let usedTrailingProse = false;
527
1378
  try {
528
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.
529
1404
  protocolErrors = 0;
530
1405
  } catch (error) {
531
1406
  if (!(error instanceof ProtocolError)) {
@@ -565,12 +1440,21 @@ export class AgentRuntime {
565
1440
  await this.emit("protocol.plain_answer", {
566
1441
  snippet: plainAnswer.slice(0, 200),
567
1442
  });
568
- await this.session.update({
569
- phase: "idle",
570
- lastMessage: plainAnswer,
571
- pendingToolResult: null,
572
- });
573
- 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
+ ));
574
1458
  await this.emit("run.completed", {
575
1459
  message: plainAnswer,
576
1460
  plainAnswer: true,
@@ -591,79 +1475,39 @@ export class AgentRuntime {
591
1475
  `Protocol failed ${protocolErrors} consecutive times: ${error.message}`,
592
1476
  );
593
1477
  }
594
- await this.sendMessage(serializeProtocolError(error));
1478
+ await this.sendMessage(serializeProtocolError(error), {
1479
+ allowAssistantContinuation: true,
1480
+ outboundKind: "protocol_correction",
1481
+ runtimeTurn: turnNumber + 1,
1482
+ });
595
1483
  continue;
596
1484
  }
597
1485
 
598
- // When the run finishes, some models (GLM especially) put their real
599
- // deliverable AFTER the envelope: a short done/true stub followed by the
600
- // full markdown/HTML answer. That trailing prose is pure display content
601
- // (it cannot trigger a tool), so merge it into the final message instead
602
- // of dropping it. Non-done turns keep the protocol message untouched.
603
- let finalMessage = parsed.message;
604
- let usedTrailingProse = false;
605
- if (parsed.done) {
606
- const trailing = extractTrailingProse(raw);
607
- if (trailing) {
608
- finalMessage = [parsed.message.trim(), trailing]
609
- .filter(Boolean)
610
- .join("\n\n");
611
- usedTrailingProse = true;
612
- }
613
- }
614
-
615
1486
  // Record the assistant's turn in the canonical transcript. The raw XML is
616
1487
  // the web rendering; the transcript keeps the plain progress message.
617
1488
  if (finalMessage?.trim()) {
618
- await this.session.appendTranscriptItem(
1489
+ await this.#appendHandoffTranscript(
1490
+ activeHandoff,
1491
+ "assistant",
619
1492
  assistantMessage(finalMessage),
620
1493
  );
621
1494
  }
622
1495
 
623
1496
  if (parsed.done) {
624
- if (!finalMessage.trim()) {
625
- const error = new ProtocolError(
626
- "done=true requires a non-empty final message.",
627
- );
628
- await this.emit("protocol.invalid", { message: error.message });
629
- await this.sendMessage(serializeProtocolError(error));
630
- continue;
631
- }
632
- // Some models (DeepSeek especially) write their tool request inside
633
- // the <message> of a done=true envelope instead of a <tool_call>
634
- // element. Completing there would swallow the tool call and end the
635
- // run with raw XML as the "answer". Treat it as a format slip: the
636
- // protocol-error feedback tells the model where tool calls go, and
637
- // the normal retry limit still applies.
638
- if (
639
- !parsed.toolCall
640
- && /<tool_calls[\s>]|<tool_call[\s>]|<invoke[\s>]/i.test(finalMessage)
641
- ) {
642
- const error = new ProtocolError(
643
- "Tool calls must use the <tool_call> element, not the <message> text.",
644
- );
645
- protocolErrors += 1;
646
- await this.emit("protocol.invalid", {
647
- message: error.message,
648
- count: protocolErrors,
649
- });
650
- if (protocolErrors >= this.limits.maxProtocolErrors) {
651
- throw new ProtocolError(
652
- `Protocol failed ${protocolErrors} consecutive times: ${error.message}`,
653
- );
654
- }
655
- await this.sendMessage(serializeProtocolError(error));
656
- continue;
657
- }
658
1497
  // done=true completes the run. A request may be answered directly
659
1498
  // (no tool call) or after any number of tools; the runtime does not
660
1499
  // second-guess whether "enough" work happened — that is the model's
661
1500
  // and the user's call, not a keyword heuristic.
662
- await this.session.update({
663
- phase: "idle",
664
- lastMessage: finalMessage,
665
- pendingToolResult: null,
666
- });
1501
+ await this.#queueSessionOperation(() => (
1502
+ this.session.clearPendingAssistantTurn(
1503
+ activeHandoff.handoffId,
1504
+ {
1505
+ phase: "idle",
1506
+ lastMessage: finalMessage,
1507
+ pendingToolResult: null,
1508
+ },
1509
+ )
1510
+ ));
667
1511
  await this.emit("run.completed", {
668
1512
  message: finalMessage,
669
1513
  usedTrailingProse,
@@ -692,6 +1536,11 @@ export class AgentRuntime {
692
1536
  + "If you need to take action on the user's machine, request one local tool. "
693
1537
  + "If you need information from the user, ask one specific question in <message> "
694
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
+ },
695
1544
  );
696
1545
  continue;
697
1546
  }
@@ -734,16 +1583,24 @@ export class AgentRuntime {
734
1583
  await this.onEvent?.(completionEvent);
735
1584
  // Record the rejected call and its error output so the transcript stays
736
1585
  // a faithful, replay-free record of what the model attempted.
737
- await this.session.appendTranscriptItem(functionCall({
738
- name: normalizedCall.name,
739
- args: normalizedCall.args,
740
- callId: normalizedCall.id,
741
- }));
742
- await this.session.appendTranscriptItem(functionCallOutput({
743
- callId: result.callId,
744
- output: toolResultOutput(result),
745
- }));
746
- 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 });
747
1604
  awaitingPendingAcknowledgement = true;
748
1605
  await this.emit("tool.result_sent", {
749
1606
  id: result.callId,
@@ -757,11 +1614,15 @@ export class AgentRuntime {
757
1614
  name: preparedCall.name,
758
1615
  args: preparedCall.args,
759
1616
  });
760
- await this.session.appendTranscriptItem(functionCall({
761
- name: preparedCall.name,
762
- args: preparedCall.args,
763
- callId: preparedCall.id,
764
- }));
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
+ );
765
1626
 
766
1627
  const isReadTool = preparedCall.definition.risk === "read";
767
1628
  const sideEffect = isReadTool ? null : identity;
@@ -933,11 +1794,15 @@ export class AgentRuntime {
933
1794
  }
934
1795
  }
935
1796
 
936
- await this.session.appendTranscriptItem(functionCallOutput({
937
- callId: result.callId,
938
- output: toolResultOutput(result),
939
- }));
940
- 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 });
941
1806
  awaitingPendingAcknowledgement = true;
942
1807
  await this.emit("tool.result_sent", {
943
1808
  id: result.callId,