@keyou007/agent-bot 0.1.24-alpha.8 → 0.1.25-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +12 -6
  3. package/README.zh.md +12 -6
  4. package/dist/codex/CodexRuntime.js +141 -21
  5. package/dist/codex/CodexRuntime.js.map +1 -1
  6. package/dist/codex/ProviderSwitch.js +1 -1
  7. package/dist/codex/ProviderSwitch.js.map +1 -1
  8. package/dist/feishu/CardRenderer.js +10 -6
  9. package/dist/feishu/CardRenderer.js.map +1 -1
  10. package/dist/feishu/FeishuConnector.js +10 -3
  11. package/dist/feishu/FeishuConnector.js.map +1 -1
  12. package/dist/feishu/MergedForwardMessage.js +14 -11
  13. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  14. package/dist/local-files/TurnPreviewPage.js +26 -10
  15. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  16. package/dist/presentation/visibleTurnActivities.js +13 -0
  17. package/dist/presentation/visibleTurnActivities.js.map +1 -0
  18. package/dist/proxy/ProxySessionController.js +169 -92
  19. package/dist/proxy/ProxySessionController.js.map +1 -1
  20. package/dist/runtime/responseErrors.js +60 -0
  21. package/dist/runtime/responseErrors.js.map +1 -0
  22. package/docs/technical-reference.md +12 -2
  23. package/docs/technical-reference.zh.md +12 -2
  24. package/npm-shrinkwrap.json +2 -2
  25. package/package.json +1 -1
  26. package/skills/agent-bot/SKILL.md +5 -1
  27. package/src/codex/CodexRuntime.ts +139 -20
  28. package/src/codex/ProviderSwitch.ts +1 -1
  29. package/src/feishu/CardRenderer.ts +10 -5
  30. package/src/feishu/FeishuConnector.ts +10 -3
  31. package/src/feishu/MergedForwardMessage.ts +14 -11
  32. package/src/feishu/types.ts +3 -0
  33. package/src/local-files/TurnPreviewPage.ts +25 -10
  34. package/src/presentation/visibleTurnActivities.ts +12 -0
  35. package/src/proxy/ProxySessionController.ts +175 -105
  36. package/src/runtime/responseErrors.ts +62 -0
  37. package/src/runtime/types.ts +7 -1
@@ -59,6 +59,7 @@ import type { TurnActivity, TurnViewState } from "../presentation/turnViewTypes.
59
59
  import { createTurnViewState, hydrateTurnViewState } from "../presentation/TurnStateReducer.js";
60
60
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
61
61
  import type { AgentRuntimeRegistry } from "../runtime/AgentRuntimeRegistry.js";
62
+ import { displayRuntimeError, isResponseContinuationError } from "../runtime/responseErrors.js";
62
63
  import type {
63
64
  AgentRuntime,
64
65
  ApprovalDecision,
@@ -76,6 +77,7 @@ import type {
76
77
  RuntimeExecutionSettings,
77
78
  RuntimePrompt,
78
79
  RuntimeSession,
80
+ RuntimeSettingsReplacement,
79
81
  } from "../runtime/types.js";
80
82
  import {
81
83
  isActiveShellCommandJob,
@@ -120,9 +122,10 @@ interface StartTurnOptions {
120
122
  displayPrompt?: string;
121
123
  preserveAttemptOnFailure?: boolean;
122
124
  approvedPlanTurnId?: string;
125
+ responseContinuationTurnId?: string;
123
126
  }
124
127
 
125
- interface PendingForwardAttachment {
128
+ interface PendingMessageReference {
126
129
  contextKey: string;
127
130
  userId?: string;
128
131
  attachmentMessageId?: string;
@@ -130,11 +133,9 @@ interface PendingForwardAttachment {
130
133
  resolveAttachment: (message: IncomingMessage | undefined) => void;
131
134
  }
132
135
 
133
- interface ForwardAttachmentReservation {
136
+ interface MessageReferenceReservation {
134
137
  sourceMessageId: string;
135
- sourceKind: "merged_forward" | "resource";
136
- pending: PendingForwardAttachment;
137
- registry: Map<string, PendingForwardAttachment>;
138
+ pending: PendingMessageReference;
138
139
  }
139
140
 
140
141
  interface PreparedTopicRootPrompt {
@@ -189,6 +190,7 @@ const MESSAGE_RECEIVED_REACTION = "OnIt";
189
190
  const MESSAGE_COMPLETED_REACTION = "DONE";
190
191
  const MESSAGE_FAILED_REACTION = "ERROR";
191
192
  const MESSAGE_CANCELLED_REACTION = "CrossMark";
193
+ const INBOUND_MESSAGE_DELAY_WARNING_MS = 60_000;
192
194
  const SESSION_PAGE_SIZE = 10;
193
195
  const SESSION_PROMPT_PREVIEW_LENGTH = 50;
194
196
  const DIRECTORY_PAGE_SIZE = 15;
@@ -202,7 +204,7 @@ const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
202
204
  const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
203
205
  const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
204
206
  const APP_SERVER_RELEASE_POLL_INTERVAL_MS = 500;
205
- const FORWARD_ATTACHMENT_WINDOW_MS = 1_500;
207
+ const REFERENCE_INSTRUCTION_WINDOW_MS = 1_500;
206
208
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
207
209
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
208
210
  const TURN_STOP_REQUESTED_MESSAGE = "已发送停止信号,任务会自动停止。";
@@ -566,6 +568,7 @@ export class ProxySessionController {
566
568
  private readonly lastSessionListings = new Map<string, string[]>();
567
569
  private readonly threadInitializations = new Map<string, Promise<void>>();
568
570
  private readonly llmRetryingSessions = new Set<string>();
571
+ private readonly responseContinuations = new Map<string, { turnId: string; abort: AbortController }>();
569
572
  private readonly planHandoffs = new Map<string, string>();
570
573
  private readonly retriedFailureTurnIds = new Set<string>();
571
574
  private readonly queuedPromptRetryTimers = new Map<string, NodeJS.Timeout>();
@@ -573,8 +576,7 @@ export class ProxySessionController {
573
576
  private readonly recoveryRetryStartedAt = new Map<string, number>();
574
577
  private readonly recoveredLiveAttempts = new Set<string>();
575
578
  private recoveryClosed = false;
576
- private readonly pendingMergedForwards = new Map<string, PendingForwardAttachment>();
577
- private readonly pendingResourceForwards = new Map<string, PendingForwardAttachment>();
579
+ private readonly pendingMessageReferences = new Map<string, PendingMessageReference>();
578
580
  private readonly relatedReactionMessageIds = new Map<string, string[]>();
579
581
  private readonly shellCommandJobMonitors = new Map<string, ShellCommandJobMonitor>();
580
582
  private readonly appServerReleaseSchedules = new Map<string, AppServerReleaseSchedule>();
@@ -597,6 +599,7 @@ export class ProxySessionController {
597
599
  path.join(path.dirname(config.storage.sqlitePath), "command-jobs"),
598
600
  ),
599
601
  private readonly threadWriterProcesses: ThreadWriterProcessController = new SystemThreadWriterProcessController(),
602
+ private readonly referenceInstructionWindowMs = REFERENCE_INSTRUCTION_WINDOW_MS,
600
603
  ) {
601
604
  for (const [, runtime] of this.runtimes.entries()) {
602
605
  this.unsubscribe.push(
@@ -643,14 +646,29 @@ export class ProxySessionController {
643
646
  // For accepted messages, claim durable deduplication before acknowledgement so event
644
647
  // retries cannot add duplicate reactions.
645
648
  if (!this.store.claimInboundEvent(message.messageId, "message")) return;
646
- const attachmentReservation = message.mergedForwardMessageId
647
- ? undefined
648
- : this.reserveForwardAttachment(message);
649
- const pendingMergedForward = message.mergedForwardMessageId
650
- ? this.registerPendingForwardAttachment(message, this.pendingMergedForwards)
651
- : undefined;
652
- const pendingResourceForward = !attachmentReservation && isForwardAttachmentSource(message)
653
- ? this.registerPendingForwardAttachment(message, this.pendingResourceForwards)
649
+ const acceptedAt = Date.now();
650
+ const receivedAt = message.receivedAt ?? acceptedAt;
651
+ const deliveryDelayMs = message.createdAt === undefined
652
+ ? 0 : Math.max(0, receivedAt - message.createdAt);
653
+ const dispatchDelayMs = Math.max(0, acceptedAt - receivedAt);
654
+ if (deliveryDelayMs + dispatchDelayMs >= INBOUND_MESSAGE_DELAY_WARNING_MS) {
655
+ this.logger.warn(
656
+ {
657
+ messageId: message.messageId,
658
+ eventId: message.eventId,
659
+ contextKey: message.contextKey,
660
+ messageCreatedAt: message.createdAt,
661
+ receivedAt,
662
+ acceptedAt,
663
+ deliveryDelayMs,
664
+ dispatchDelayMs,
665
+ },
666
+ "Accepted a delayed Feishu message.",
667
+ );
668
+ }
669
+ const attachmentReservation = this.reserveReferenceInstruction(message);
670
+ const pendingReference = !attachmentReservation && isReferenceSource(message)
671
+ ? this.registerPendingReference(message)
654
672
  : undefined;
655
673
  try {
656
674
  const reactionId = await this.outbound.addReaction(
@@ -668,8 +686,9 @@ export class ProxySessionController {
668
686
  );
669
687
  }
670
688
  await this.waitForCurrentSessionReset(message.contextKey);
671
- if (attachmentReservation && this.completeForwardAttachment(message, attachmentReservation)) {
672
- this.store.audit(message.contextKey, `${attachmentReservation.sourceKind}_attachment_coalesced`, {
689
+ if (this.recoveryClosed) return;
690
+ if (attachmentReservation && this.completeReferenceInstruction(message, attachmentReservation)) {
691
+ this.store.audit(message.contextKey, "referenced_message_coalesced", {
673
692
  messageId: message.messageId,
674
693
  parentMessageId: message.parentMessageId,
675
694
  });
@@ -690,24 +709,24 @@ export class ProxySessionController {
690
709
  this.store.recordChatContext(chatContextKey, message.chatType);
691
710
  this.store.markChatActive(chatContextKey);
692
711
  }
693
- if (pendingResourceForward) {
694
- const resourceMessage = message;
695
- const attachment = await this.waitForForwardAttachment(
712
+ if (pendingReference && !message.mergedForwardMessageId) {
713
+ const sourceMessage = message;
714
+ const attachment = await this.waitForReferenceInstruction(
696
715
  message.messageId,
697
- pendingResourceForward,
698
- this.pendingResourceForwards,
716
+ pendingReference,
699
717
  );
718
+ if (this.recoveryClosed) return;
700
719
  if (attachment) {
701
720
  coalescedForwardAttachment = true;
702
- const sourceText = resourceMessage.text.trim();
703
- const instruction = normalizeFeishuPostText(attachment.text)
704
- || (sourceText ? DEFAULT_REFERENCED_MESSAGE_INSTRUCTION : defaultResourcePrompt(resourceMessage));
705
- forwardedImages = (resourceMessage.images ?? []).map((image) => ({
706
- messageId: resourceMessage.messageId,
721
+ const sourceText = sourceMessage.text.trim();
722
+ const instruction = normalizeFeishuPostText(attachment.displayText ?? attachment.text)
723
+ || (sourceText ? DEFAULT_REFERENCED_MESSAGE_INSTRUCTION : defaultResourcePrompt(sourceMessage));
724
+ forwardedImages = (sourceMessage.images ?? []).map((image) => ({
725
+ messageId: sourceMessage.messageId,
707
726
  imageKey: image.imageKey,
708
727
  }));
709
- forwardedFiles = (resourceMessage.files ?? []).map((file) => ({
710
- messageId: resourceMessage.messageId,
728
+ forwardedFiles = (sourceMessage.files ?? []).map((file) => ({
729
+ messageId: sourceMessage.messageId,
711
730
  fileKey: file.fileKey,
712
731
  fileName: file.fileName,
713
732
  }));
@@ -716,7 +735,7 @@ export class ProxySessionController {
716
735
  text: sourceText ? referencedMessagePrompt(instruction, sourceText) : instruction,
717
736
  displayText: instruction,
718
737
  };
719
- this.relatedReactionMessageIds.set(message.messageId, [resourceMessage.messageId]);
738
+ this.relatedReactionMessageIds.set(message.messageId, [sourceMessage.messageId]);
720
739
  }
721
740
  }
722
741
  if (message.mergedForwardMessageId) {
@@ -728,16 +747,15 @@ export class ProxySessionController {
728
747
  (content) => ({ content } as const),
729
748
  (error: unknown) => ({ error } as const),
730
749
  );
731
- const attachment = pendingMergedForward
732
- ? await this.waitForForwardAttachment(
750
+ const attachment = pendingReference
751
+ ? await this.waitForReferenceInstruction(
733
752
  message.messageId,
734
- pendingMergedForward,
735
- this.pendingMergedForwards,
753
+ pendingReference,
736
754
  )
737
755
  : undefined;
738
756
  if (attachment) {
739
757
  coalescedForwardAttachment = true;
740
- const instruction = normalizeFeishuPostText(attachment.text)
758
+ const instruction = normalizeFeishuPostText(attachment.displayText ?? attachment.text)
741
759
  || ((attachment.images?.length ?? 0) > 0 ? "请查看附带的内容。" : "请结合参考聊天记录处理。");
742
760
  message = {
743
761
  ...attachment,
@@ -747,6 +765,7 @@ export class ProxySessionController {
747
765
  this.relatedReactionMessageIds.set(message.messageId, [mergedForwardMessage.messageId]);
748
766
  }
749
767
  const result = await mergedForwardResult;
768
+ if (this.recoveryClosed) return;
750
769
  if ("error" in result) {
751
770
  const replyTarget = message.replyInThread
752
771
  ? { messageId: message.messageId, replyInThread: true as const }
@@ -823,6 +842,9 @@ export class ProxySessionController {
823
842
  const fileCount = fileReferences.length;
824
843
  this.store.audit(message.contextKey, "incoming_message", {
825
844
  messageId: message.messageId,
845
+ eventId: message.eventId,
846
+ messageCreatedAt: message.createdAt,
847
+ receivedAt: message.receivedAt,
826
848
  text: message.text,
827
849
  ...(imageCount > 0 ? { imageCount } : {}),
828
850
  ...(fileCount > 0 ? { fileCount } : {}),
@@ -1400,10 +1422,8 @@ export class ProxySessionController {
1400
1422
  this.remoteTurnHistoryRefreshes.clear();
1401
1423
  this.forkHistoryCursors.clear();
1402
1424
  this.turnHistoryHydrations.clear();
1403
- for (const pending of this.pendingMergedForwards.values()) pending.resolveAttachment(undefined);
1404
- this.pendingMergedForwards.clear();
1405
- for (const pending of this.pendingResourceForwards.values()) pending.resolveAttachment(undefined);
1406
- this.pendingResourceForwards.clear();
1425
+ for (const pending of this.pendingMessageReferences.values()) pending.resolveAttachment(undefined);
1426
+ this.pendingMessageReferences.clear();
1407
1427
  this.relatedReactionMessageIds.clear();
1408
1428
  for (const timer of this.queuedPromptRetryTimers.values()) clearTimeout(timer);
1409
1429
  this.queuedPromptRetryTimers.clear();
@@ -3976,6 +3996,16 @@ export class ProxySessionController {
3976
3996
  localImagePaths?: string[], options: StartTurnOptions = {},
3977
3997
  ): Promise<string> {
3978
3998
  return this.settingsOperations.run(loaded.record.localSessionId, async () => {
3999
+ if (options.responseContinuationTurnId) {
4000
+ const current = this.store.getSession(loaded.record.localSessionId);
4001
+ if (!current || current.status === "closed" || current.lastTurnId !== options.responseContinuationTurnId
4002
+ || this.sessionResets.has(current.localSessionId)
4003
+ || this.responseContinuations.get(current.localSessionId)?.turnId !== options.responseContinuationTurnId
4004
+ || this.store.getPendingProviderSettings(current.localSessionId)) {
4005
+ throw new Error("任务或运行设置已变化,未自动恢复模型续接。请发送消息继续。");
4006
+ }
4007
+ return this.startTurnNow(loaded, text, replyTarget, localImagePaths, options);
4008
+ }
3979
4009
  try {
3980
4010
  await this.applyPendingProviderSettings(loaded);
3981
4011
  } catch (error) {
@@ -4050,17 +4080,27 @@ export class ProxySessionController {
4050
4080
  || this.sessionResets.has(loaded.record.localSessionId))) {
4051
4081
  throw new Error("方案执行已取消。");
4052
4082
  }
4053
- turnId = await loaded.runtime.startTurn(
4054
- loaded.record.localSessionId,
4055
- options.approvedPlanTurnId
4056
- ? { text: runtimeText, collaborationMode: "default" }
4057
- : runtimePrompt(runtimeText, localImagePaths),
4058
- );
4083
+ if (options.responseContinuationTurnId) {
4084
+ const recovery = this.responseContinuations.get(loaded.record.localSessionId);
4085
+ if (recovery?.turnId !== options.responseContinuationTurnId
4086
+ || this.sessionResets.has(loaded.record.localSessionId) || !loaded.runtime.recoverResponseContinuation) {
4087
+ throw new Error("模型续接恢复已取消。");
4088
+ }
4089
+ turnId = await loaded.runtime.recoverResponseContinuation(loaded.record.localSessionId, options.responseContinuationTurnId, recovery.abort.signal);
4090
+ } else {
4091
+ turnId = await loaded.runtime.startTurn(
4092
+ loaded.record.localSessionId,
4093
+ options.approvedPlanTurnId
4094
+ ? { text: runtimeText, collaborationMode: "default" }
4095
+ : runtimePrompt(runtimeText, localImagePaths),
4096
+ );
4097
+ }
4059
4098
  break;
4060
4099
  } catch (error) {
4061
4100
  const message = error instanceof Error ? error.message : String(error);
4062
4101
  const attempt = this.store.getTurnAttempt(attemptId);
4063
4102
  const retryAttempt = attempt
4103
+ && !options.responseContinuationTurnId
4064
4104
  && isRetryableLlmTurnFailure(message)
4065
4105
  && attempt.retryCount < MAX_LLM_TURN_RETRIES
4066
4106
  ? this.store.prepareUnstartedTurnAttemptRetry(attemptId)
@@ -4794,7 +4834,23 @@ export class ProxySessionController {
4794
4834
  : undefined;
4795
4835
  if (event.type === "turn_failed") {
4796
4836
  if (this.retriedFailureTurnIds.has(event.turnId)) return;
4797
- if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
4837
+ const source = this.store.getSession(event.sessionId);
4838
+ const responseContinuation = failedAttempt?.retryCount === 0 && failedAttempt.status !== "cancelling"
4839
+ && source && source.status !== "closed" && source.lastTurnId === event.turnId
4840
+ && !this.sessionResets.has(event.sessionId) && !this.store.getPendingProviderSettings(event.sessionId)
4841
+ && Boolean(this.runtimes.forAgent(source.agentName).recoverResponseContinuation)
4842
+ && isResponseContinuationError(event.message);
4843
+ if (failedAttempt && responseContinuation) {
4844
+ const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
4845
+ if (retryAttempt) {
4846
+ this.retriedFailureTurnIds.add(event.turnId);
4847
+ this.responseContinuations.set(event.sessionId, { turnId: event.turnId, abort: new AbortController() });
4848
+ await this.retryFailedLlmTurn(event, retryAttempt, true);
4849
+ return;
4850
+ }
4851
+ }
4852
+ if (failedAttempt && !this.responseContinuations.has(event.sessionId)
4853
+ && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
4798
4854
  const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
4799
4855
  if (retryAttempt) {
4800
4856
  this.retriedFailureTurnIds.add(event.turnId);
@@ -4957,6 +5013,7 @@ export class ProxySessionController {
4957
5013
  private async retryFailedLlmTurn(
4958
5014
  event: Extract<RuntimeEvent, { type: "turn_failed" }>,
4959
5015
  attempt: TurnAttemptRecord,
5016
+ responseContinuation = false,
4960
5017
  ): Promise<void> {
4961
5018
  const retryNumber = attempt.retryCount;
4962
5019
  const session = this.store.getSession(event.sessionId);
@@ -4974,14 +5031,17 @@ export class ProxySessionController {
4974
5031
  attemptId: attempt.attemptId,
4975
5032
  failedTurnId: event.turnId,
4976
5033
  retryNumber,
4977
- maxRetries: MAX_LLM_TURN_RETRIES,
5034
+ maxRetries: responseContinuation ? 1 : MAX_LLM_TURN_RETRIES,
5035
+ responseContinuation,
4978
5036
  error: event.message,
4979
5037
  });
4980
5038
 
4981
5039
  try {
4982
5040
  await this.outbound.onEvent({
4983
5041
  ...event,
4984
- message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
5042
+ message: responseContinuation
5043
+ ? `${event.message}\n\n模型续接状态失效,正在恢复连接并继续(1/1)。`
5044
+ : `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
4985
5045
  });
4986
5046
  } catch (error) {
4987
5047
  this.logger.warn(
@@ -4998,21 +5058,26 @@ export class ProxySessionController {
4998
5058
  const loaded = await this.loadSession(current, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
4999
5059
  const retryTurnId = await this.startTurn(
5000
5060
  loaded,
5001
- llmRetryRuntimePrompt(attempt.promptText, retryNumber),
5061
+ responseContinuation ? "" : llmRetryRuntimePrompt(attempt.promptText, retryNumber),
5002
5062
  replyTarget,
5003
- attempt.localImagePaths,
5063
+ responseContinuation ? undefined : attempt.localImagePaths,
5004
5064
  {
5005
5065
  attemptId: attempt.attemptId,
5006
5066
  messageId: attempt.messageId,
5007
- displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
5067
+ displayPrompt: responseContinuation ? `恢复模型续接:${attempt.promptText}` : llmRetryCardPrompt(attempt.promptText, retryNumber),
5008
5068
  preserveAttemptOnFailure: true,
5069
+ ...(responseContinuation ? { responseContinuationTurnId: event.turnId } : {}),
5009
5070
  },
5010
5071
  );
5011
5072
  this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
5012
5073
  } catch (error) {
5013
- this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
5014
- this.store.updateSession(session.localSessionId, { status: "failed" });
5015
- await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError: unknown) => {
5074
+ const aborted = responseContinuation && !this.responseContinuations.has(session.localSessionId);
5075
+ this.store.updateTurnAttempt(attempt.attemptId, { status: aborted ? "cancelled" : "failed" });
5076
+ const latest = this.store.getSession(session.localSessionId);
5077
+ if (!responseContinuation || (latest?.lastTurnId === event.turnId && latest.status !== "closed")) {
5078
+ this.store.updateSession(session.localSessionId, { status: aborted ? "ready" : "failed" });
5079
+ }
5080
+ await this.finalizeTurnMessageReactions(event.turnId, aborted ? "cancelled" : "failed").catch((reactionError: unknown) => {
5016
5081
  this.logger.warn(
5017
5082
  { error: reactionError, sessionId: session.localSessionId, turnId: event.turnId },
5018
5083
  "Failed to finalize reactions after an LLM retry could not start.",
@@ -5022,9 +5087,16 @@ export class ProxySessionController {
5022
5087
  { error, sessionId: session.localSessionId, retryNumber },
5023
5088
  "Failed to start an automatic LLM turn retry.",
5024
5089
  );
5090
+ if (responseContinuation && !aborted) {
5091
+ await this.outbound.sendText(attempt.contextKey,
5092
+ `模型续接恢复未能启动:${displayRuntimeError(runtimeErrorMessage(error))}。请发送消息继续。`);
5093
+ }
5025
5094
  await this.scheduleNextQueuedPrompt(session.localSessionId);
5026
5095
  } finally {
5027
5096
  this.llmRetryingSessions.delete(session.localSessionId);
5097
+ if (this.responseContinuations.get(session.localSessionId)?.turnId === event.turnId) {
5098
+ this.responseContinuations.delete(session.localSessionId);
5099
+ }
5028
5100
  }
5029
5101
  }
5030
5102
 
@@ -5146,10 +5218,7 @@ export class ProxySessionController {
5146
5218
  }
5147
5219
  }
5148
5220
 
5149
- private registerPendingForwardAttachment(
5150
- message: IncomingMessage,
5151
- registry: Map<string, PendingForwardAttachment>,
5152
- ): PendingForwardAttachment {
5221
+ private registerPendingReference(message: IncomingMessage): PendingMessageReference {
5153
5222
  let resolveAttachment!: (message: IncomingMessage | undefined) => void;
5154
5223
  const attachmentPromise = new Promise<IncomingMessage | undefined>((resolve) => {
5155
5224
  resolveAttachment = resolve;
@@ -5160,61 +5229,51 @@ export class ProxySessionController {
5160
5229
  attachmentPromise,
5161
5230
  resolveAttachment,
5162
5231
  };
5163
- registry.set(message.messageId, pending);
5232
+ this.pendingMessageReferences.set(message.messageId, pending);
5164
5233
  return pending;
5165
5234
  }
5166
5235
 
5167
- private reserveForwardAttachment(message: IncomingMessage): ForwardAttachmentReservation | undefined {
5168
- if (!message.parentMessageId || isCommandMessage(message)) return undefined;
5169
- const candidates = [
5170
- { sourceKind: "merged_forward" as const, registry: this.pendingMergedForwards },
5171
- { sourceKind: "resource" as const, registry: this.pendingResourceForwards },
5172
- ];
5173
- for (const candidate of candidates) {
5174
- const pending = candidate.registry.get(message.parentMessageId);
5175
- if (!pending || pending.contextKey !== message.contextKey
5176
- || pending.userId !== message.userId || pending.attachmentMessageId) continue;
5177
- pending.attachmentMessageId = message.messageId;
5178
- return {
5179
- sourceMessageId: message.parentMessageId,
5180
- sourceKind: candidate.sourceKind,
5181
- pending,
5182
- registry: candidate.registry,
5183
- };
5184
- }
5185
- return undefined;
5236
+ private reserveReferenceInstruction(message: IncomingMessage): MessageReferenceReservation | undefined {
5237
+ const parentMessageId = quotedMessageId(message);
5238
+ if (!parentMessageId || isCommandMessage(message)) return undefined;
5239
+ const pending = this.pendingMessageReferences.get(parentMessageId);
5240
+ if (!pending || pending.contextKey !== message.contextKey
5241
+ || pending.userId !== message.userId || pending.attachmentMessageId) return undefined;
5242
+ pending.attachmentMessageId = message.messageId;
5243
+ return { sourceMessageId: parentMessageId, pending };
5186
5244
  }
5187
5245
 
5188
- private completeForwardAttachment(
5246
+ private completeReferenceInstruction(
5189
5247
  message: IncomingMessage,
5190
- reservation: ForwardAttachmentReservation,
5248
+ reservation: MessageReferenceReservation,
5191
5249
  ): boolean {
5192
- if (!message.parentMessageId) return false;
5193
- if (reservation.sourceMessageId !== message.parentMessageId) return false;
5194
- if (reservation.registry.get(message.parentMessageId) !== reservation.pending) return false;
5250
+ const parentMessageId = quotedMessageId(message);
5251
+ if (!parentMessageId || reservation.sourceMessageId !== parentMessageId) return false;
5252
+ if (this.pendingMessageReferences.get(parentMessageId) !== reservation.pending) return false;
5195
5253
  if (reservation.pending.attachmentMessageId !== message.messageId) return false;
5196
5254
  reservation.pending.resolveAttachment(message);
5197
5255
  return true;
5198
5256
  }
5199
5257
 
5200
- private async waitForForwardAttachment(
5258
+ private async waitForReferenceInstruction(
5201
5259
  messageId: string,
5202
- pending: PendingForwardAttachment,
5203
- registry: Map<string, PendingForwardAttachment>,
5260
+ pending: PendingMessageReference,
5204
5261
  ): Promise<IncomingMessage | undefined> {
5205
- const timer = setTimeout(() => {
5262
+ const expire = (): void => {
5206
5263
  // Arrival reserves the pair before acknowledgement. A slow Reaction must not split it.
5207
5264
  if (pending.attachmentMessageId) return;
5208
- if (registry.get(messageId) === pending) registry.delete(messageId);
5265
+ if (this.pendingMessageReferences.get(messageId) === pending) this.pendingMessageReferences.delete(messageId);
5209
5266
  pending.resolveAttachment(undefined);
5210
- }, FORWARD_ATTACHMENT_WINDOW_MS);
5211
- timer.unref?.();
5267
+ };
5268
+ const timer = this.referenceInstructionWindowMs > 0 ? setTimeout(expire, this.referenceInstructionWindowMs) : undefined;
5269
+ if (timer) timer.unref?.();
5270
+ else expire();
5212
5271
  try {
5213
5272
  return await pending.attachmentPromise;
5214
5273
  } finally {
5215
- clearTimeout(timer);
5216
- if (registry.get(messageId) === pending) {
5217
- registry.delete(messageId);
5274
+ if (timer) clearTimeout(timer);
5275
+ if (this.pendingMessageReferences.get(messageId) === pending) {
5276
+ this.pendingMessageReferences.delete(messageId);
5218
5277
  }
5219
5278
  }
5220
5279
  }
@@ -5291,6 +5350,8 @@ export class ProxySessionController {
5291
5350
  }
5292
5351
 
5293
5352
  private async cancelSession(record: SessionRecord): Promise<void> {
5353
+ this.responseContinuations.get(record.localSessionId)?.abort.abort();
5354
+ this.responseContinuations.delete(record.localSessionId);
5294
5355
  this.planHandoffs.delete(record.localSessionId);
5295
5356
  const runtime = this.runtimes.forAgent(record.agentName);
5296
5357
  if (runtime.kind === "codex" && record.remoteSessionId && runtime.interruptRemoteTurn) {
@@ -5370,6 +5431,8 @@ export class ProxySessionController {
5370
5431
  }
5371
5432
 
5372
5433
  private async runSessionReset(sessionId: string, operation: () => Promise<void>): Promise<void> {
5434
+ this.responseContinuations.get(sessionId)?.abort.abort();
5435
+ this.responseContinuations.delete(sessionId);
5373
5436
  this.planHandoffs.delete(sessionId);
5374
5437
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
5375
5438
  const reset = previous.catch(() => undefined).then(operation);
@@ -5844,9 +5907,16 @@ export class ProxySessionController {
5844
5907
  if (!loaded.runtime.setExecutionSettings) throw new Error("当前运行时不支持 Provider 设置。");
5845
5908
  loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
5846
5909
  const remoteSessionId = loaded.session.remoteSessionId;
5847
- const persist = (candidate: RuntimeSession): Promise<void> => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
5910
+ const persist = (candidate: RuntimeSession, replacement?: RuntimeSettingsReplacement): Promise<void> => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
5848
5911
  if (candidate.remoteSessionId !== remoteSessionId) {
5849
- throw new Error("设置切换返回了不同的任务 ID,未保存设置;不允许替换任务或更改历史。");
5912
+ const saved = this.store.getSession(loaded.record.localSessionId);
5913
+ if (replacement?.emptyRemoteSessionId !== remoteSessionId || !saved || saved.remoteSessionId !== remoteSessionId
5914
+ || saved.status === "closed" || saved.status === "running" || saved.lastTurnId || loaded.session.activeTurnId || candidate.activeTurnId
5915
+ || candidate.localSessionId !== saved.localSessionId || !candidate.remoteSessionId
5916
+ || candidate.agentName !== saved.agentName || candidate.cwd !== saved.cwd || candidate.runtimeKind !== loaded.session.runtimeKind
5917
+ || this.store.hasTurnAttemptsForSession(saved.localSessionId) || this.store.getForkHistorySource(saved.localSessionId)) {
5918
+ throw new Error("设置切换返回了不同的任务 ID,未保存设置;仅允许已验证的空会话更换底层任务。");
5919
+ }
5850
5920
  }
5851
5921
  assertProviderSettingsApplied(candidate, settings);
5852
5922
  const agent = this.ensureAgent(loaded.record.agentName);
@@ -5854,6 +5924,7 @@ export class ProxySessionController {
5854
5924
  await this.persistAgentExecutionDefaultsNow(loaded.record.localSessionId, candidate);
5855
5925
  try {
5856
5926
  this.store.updateRuntimeSession(loaded.record.localSessionId, {
5927
+ remoteSessionId: candidate.remoteSessionId,
5857
5928
  modelProvider: candidate.modelProvider,
5858
5929
  model: candidate.model,
5859
5930
  reasoningEffort: candidate.reasoningEffort,
@@ -5866,6 +5937,7 @@ export class ProxySessionController {
5866
5937
  }
5867
5938
  });
5868
5939
  const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5940
+ loaded.record = this.store.getSession(loaded.record.localSessionId) ?? loaded.record;
5869
5941
  this.settingsOnlySessions.delete(loaded.record.localSessionId);
5870
5942
  this.outbound.updateSessionModel(loaded.record.localSessionId, session.model, session.modelProvider);
5871
5943
  return session;
@@ -7107,6 +7179,7 @@ export class ProxySessionController {
7107
7179
  }
7108
7180
 
7109
7181
  private async createDirectoryFromCard(contextKey: string, action: CardAction): Promise<void> {
7182
+ const updateMessageId = requiredCardMessageId(action.messageId);
7110
7183
  const directory = directoryActionPath(action.value.directory);
7111
7184
  await this.assertBrowsableDirectory(directory);
7112
7185
  const folderName = directoryFolderName(cardFormValue(action.value, "folderName"));
@@ -7120,9 +7193,9 @@ export class ProxySessionController {
7120
7193
  if (code === "EEXIST") throw new Error(`目录已存在:${newDirectory}`);
7121
7194
  throw new Error(`无法创建目录 ${newDirectory}:${runtimeErrorMessage(error)}`);
7122
7195
  }
7123
- await this.openDirectoryBrowser(contextKey, directory, {
7124
- updateMessageId: requiredCardMessageId(action.messageId),
7125
- page: directoryPageValue(action.value.page),
7196
+ await this.openDirectoryBrowser(contextKey, newDirectory, {
7197
+ updateMessageId,
7198
+ page: 0,
7126
7199
  });
7127
7200
  await this.outbound.sendText(contextKey, `已创建目录:${newDirectory}`);
7128
7201
  }
@@ -9758,13 +9831,10 @@ function isCommandMessage(message: IncomingMessage): boolean {
9758
9831
  return text.startsWith("/") || text.startsWith("!");
9759
9832
  }
9760
9833
 
9761
- function isForwardAttachmentSource(message: IncomingMessage): boolean {
9762
- return !message.mergedForwardMessageId
9763
- && !message.parentMessageId
9834
+ function isReferenceSource(message: IncomingMessage): boolean {
9835
+ return !quotedMessageId(message)
9764
9836
  && !message.contextKey.startsWith("console:")
9765
- && !isCommandMessage(message)
9766
- && (message.messageType === "post"
9767
- || (message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
9837
+ && !isCommandMessage(message);
9768
9838
  }
9769
9839
 
9770
9840
  function deduplicateImageReferences(
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+
3
+ export const RUNTIME_WARNING_PREFIXES = ["运行请求失败,等待 Agent 返回最终状态:", "运行请求出错,Agent 正在重试:"];
4
+
5
+ const responseErrorSchema = z.object({
6
+ type: z.literal("error"),
7
+ status: z.number().optional(),
8
+ error: z.object({
9
+ message: z.string().min(1),
10
+ code: z.string().nullish(),
11
+ param: z.string().nullish(),
12
+ type: z.string().nullish(),
13
+ }).passthrough(),
14
+ }).passthrough();
15
+
16
+ function parseResponseError(message: string): { value: z.infer<typeof responseErrorSchema>; notice: string } | undefined {
17
+ // Agent Bot may append a recovery notice after the upstream JSON error.
18
+ const boundary = message.indexOf("\n\n");
19
+ for (const end of [message.length, ...(boundary >= 0 ? [boundary] : [])]) {
20
+ try {
21
+ const parsed = responseErrorSchema.safeParse(JSON.parse(message.slice(0, end)));
22
+ if (parsed.success) return { value: parsed.data, notice: message.slice(end) };
23
+ } catch {
24
+ // Non-JSON runtime errors remain unchanged.
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ export function isResponseContinuationError(message: string): boolean {
31
+ const parsed = parseResponseError(message);
32
+ return parsed?.value.error.code === "unsupported_persisted_item_context"
33
+ && parsed.value.error.param === "previous_response_id"
34
+ && parsed.value.error.type === "invalid_request_error"
35
+ && parsed.value.status === 400
36
+ && !parsed.notice;
37
+ }
38
+
39
+ export function displayRuntimeError(message: string): string {
40
+ const prefix = RUNTIME_WARNING_PREFIXES.find((value) => message.startsWith(value));
41
+ const parsed = parseResponseError(prefix ? message.slice(prefix.length) : message);
42
+ if (!parsed) return message;
43
+ const { error, status } = parsed.value;
44
+ const details = [status === undefined ? undefined : `HTTP ${status}`, error.code, error.param];
45
+ return (prefix ?? "") + `${error.message}\n${details.filter(Boolean).join(" · ")}${parsed.notice}`.trim();
46
+ }
47
+
48
+ export function sameRuntimeError(warning: string, terminal: string): boolean {
49
+ if (warning.trim() === terminal.trim()) return true;
50
+ const parsed = parseResponseError(terminal);
51
+ if (parsed) {
52
+ const other = parseResponseError(warning);
53
+ return Boolean(other && !other.notice && (!parsed.notice || isRecoveryNotice(parsed.notice))
54
+ && JSON.stringify(other.value) === JSON.stringify(parsed.value));
55
+ }
56
+ const suffix = terminal.slice(warning.trim().length);
57
+ return terminal.startsWith(warning.trim()) && isRecoveryNotice(suffix);
58
+ }
59
+
60
+ function isRecoveryNotice(text: string): boolean {
61
+ return /^\n\n(?:检测到临时模型服务错误,正在自动重试|模型续接状态失效,正在恢复连接并继续|已自动重试)/u.test(text);
62
+ }
@@ -270,6 +270,10 @@ export interface RuntimeExecutionSettings {
270
270
  permissionMode: PermissionMode;
271
271
  }
272
272
 
273
+ export interface RuntimeSettingsReplacement {
274
+ emptyRemoteSessionId: string;
275
+ }
276
+
273
277
  export interface RuntimeReleaseResult {
274
278
  status: "released" | "busy";
275
279
  activeSessionIds?: string[];
@@ -304,6 +308,8 @@ export interface AgentRuntime {
304
308
  inspectRemoteSessionActivity?(remoteSessionId: string): Promise<RemoteSessionActivity>;
305
309
  synchronizeSession(sessionId: string): Promise<RuntimeSession>;
306
310
  startTurn(sessionId: string, prompt: RuntimePrompt): Promise<string>;
311
+ // Continue a verified Responses replay failure without resubmitting user input.
312
+ recoverResponseContinuation?(sessionId: string, failedTurnId: string, signal?: AbortSignal): Promise<string>;
307
313
  steerTurn(sessionId: string, turnId: string, prompt: RuntimePrompt): Promise<void>;
308
314
  cancelTurn(sessionId: string, turnId: string): Promise<void>;
309
315
  interruptRemoteTurn?(remoteSessionId: string, turnId: string): Promise<void>;
@@ -320,7 +326,7 @@ export interface AgentRuntime {
320
326
  sessionId: string,
321
327
  settings: RuntimeExecutionSettings,
322
328
  // Called after remote verification, before releasing the task's operation lock.
323
- persist?: (session: RuntimeSession) => Promise<void>,
329
+ persist?: (session: RuntimeSession, replacement?: RuntimeSettingsReplacement) => Promise<void>,
324
330
  ): Promise<RuntimeSession>;
325
331
  respondToApproval(sessionId: string, requestId: string, decision: ApprovalDecision): Promise<void>;
326
332
  listModels(modelProvider?: string, fallbackModel?: string): Promise<ModelOption[]>;