@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
@@ -21,6 +21,7 @@ import { classifyFileContent } from "../local-files/LocalFileViewerServer.js";
21
21
  import { errorLogValue } from "../logging/errorLogValue.js";
22
22
  import { createTurnViewState, hydrateTurnViewState } from "../presentation/TurnStateReducer.js";
23
23
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
24
+ import { displayRuntimeError, isResponseContinuationError } from "../runtime/responseErrors.js";
24
25
  import { isActiveShellCommandJob, ShellCommandJobManager, } from "../shell/ShellCommandJobManager.js";
25
26
  import { SessionOperationQueue } from "./SessionOperationQueue.js";
26
27
  import { createId } from "../utils/id.js";
@@ -41,6 +42,7 @@ const MESSAGE_RECEIVED_REACTION = "OnIt";
41
42
  const MESSAGE_COMPLETED_REACTION = "DONE";
42
43
  const MESSAGE_FAILED_REACTION = "ERROR";
43
44
  const MESSAGE_CANCELLED_REACTION = "CrossMark";
45
+ const INBOUND_MESSAGE_DELAY_WARNING_MS = 60_000;
44
46
  const SESSION_PAGE_SIZE = 10;
45
47
  const SESSION_PROMPT_PREVIEW_LENGTH = 50;
46
48
  const DIRECTORY_PAGE_SIZE = 15;
@@ -54,7 +56,7 @@ const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
54
56
  const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
55
57
  const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
56
58
  const APP_SERVER_RELEASE_POLL_INTERVAL_MS = 500;
57
- const FORWARD_ATTACHMENT_WINDOW_MS = 1_500;
59
+ const REFERENCE_INSTRUCTION_WINDOW_MS = 1_500;
58
60
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
59
61
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
60
62
  const TURN_STOP_REQUESTED_MESSAGE = "已发送停止信号,任务会自动停止。";
@@ -223,6 +225,7 @@ export class ProxySessionController {
223
225
  windowsDriveLister;
224
226
  shellCommandJobs;
225
227
  threadWriterProcesses;
228
+ referenceInstructionWindowMs;
226
229
  router = new CommandRouter();
227
230
  cardRenderer = new CardRenderer();
228
231
  messageQueues = new Map();
@@ -245,6 +248,7 @@ export class ProxySessionController {
245
248
  lastSessionListings = new Map();
246
249
  threadInitializations = new Map();
247
250
  llmRetryingSessions = new Set();
251
+ responseContinuations = new Map();
248
252
  planHandoffs = new Map();
249
253
  retriedFailureTurnIds = new Set();
250
254
  queuedPromptRetryTimers = new Map();
@@ -252,8 +256,7 @@ export class ProxySessionController {
252
256
  recoveryRetryStartedAt = new Map();
253
257
  recoveredLiveAttempts = new Set();
254
258
  recoveryClosed = false;
255
- pendingMergedForwards = new Map();
256
- pendingResourceForwards = new Map();
259
+ pendingMessageReferences = new Map();
257
260
  relatedReactionMessageIds = new Map();
258
261
  shellCommandJobMonitors = new Map();
259
262
  appServerReleaseSchedules = new Map();
@@ -262,7 +265,7 @@ export class ProxySessionController {
262
265
  recoveryActivityHeartbeat;
263
266
  startupRecovery;
264
267
  queuedPromptsRestored = false;
265
- constructor(config, store, runtimes, outbound, logger, lifecycle, shellCommandExecutor = executeShellCommand, windowsDriveLister = listWindowsDriveRoots, shellCommandJobs = new ShellCommandJobManager(path.join(path.dirname(config.storage.sqlitePath), "command-jobs")), threadWriterProcesses = new SystemThreadWriterProcessController()) {
268
+ constructor(config, store, runtimes, outbound, logger, lifecycle, shellCommandExecutor = executeShellCommand, windowsDriveLister = listWindowsDriveRoots, shellCommandJobs = new ShellCommandJobManager(path.join(path.dirname(config.storage.sqlitePath), "command-jobs")), threadWriterProcesses = new SystemThreadWriterProcessController(), referenceInstructionWindowMs = REFERENCE_INSTRUCTION_WINDOW_MS) {
266
269
  this.config = config;
267
270
  this.store = store;
268
271
  this.runtimes = runtimes;
@@ -273,6 +276,7 @@ export class ProxySessionController {
273
276
  this.windowsDriveLister = windowsDriveLister;
274
277
  this.shellCommandJobs = shellCommandJobs;
275
278
  this.threadWriterProcesses = threadWriterProcesses;
279
+ this.referenceInstructionWindowMs = referenceInstructionWindowMs;
276
280
  for (const [, runtime] of this.runtimes.entries()) {
277
281
  this.unsubscribe.push(runtime.onEvent((event) => {
278
282
  void this.handleRuntimeEvent(event).catch((error) => {
@@ -305,14 +309,26 @@ export class ProxySessionController {
305
309
  // retries cannot add duplicate reactions.
306
310
  if (!this.store.claimInboundEvent(message.messageId, "message"))
307
311
  return;
308
- const attachmentReservation = message.mergedForwardMessageId
309
- ? undefined
310
- : this.reserveForwardAttachment(message);
311
- const pendingMergedForward = message.mergedForwardMessageId
312
- ? this.registerPendingForwardAttachment(message, this.pendingMergedForwards)
313
- : undefined;
314
- const pendingResourceForward = !attachmentReservation && isForwardAttachmentSource(message)
315
- ? this.registerPendingForwardAttachment(message, this.pendingResourceForwards)
312
+ const acceptedAt = Date.now();
313
+ const receivedAt = message.receivedAt ?? acceptedAt;
314
+ const deliveryDelayMs = message.createdAt === undefined
315
+ ? 0 : Math.max(0, receivedAt - message.createdAt);
316
+ const dispatchDelayMs = Math.max(0, acceptedAt - receivedAt);
317
+ if (deliveryDelayMs + dispatchDelayMs >= INBOUND_MESSAGE_DELAY_WARNING_MS) {
318
+ this.logger.warn({
319
+ messageId: message.messageId,
320
+ eventId: message.eventId,
321
+ contextKey: message.contextKey,
322
+ messageCreatedAt: message.createdAt,
323
+ receivedAt,
324
+ acceptedAt,
325
+ deliveryDelayMs,
326
+ dispatchDelayMs,
327
+ }, "Accepted a delayed Feishu message.");
328
+ }
329
+ const attachmentReservation = this.reserveReferenceInstruction(message);
330
+ const pendingReference = !attachmentReservation && isReferenceSource(message)
331
+ ? this.registerPendingReference(message)
316
332
  : undefined;
317
333
  try {
318
334
  const reactionId = await this.outbound.addReaction(message.contextKey, message.messageId, MESSAGE_RECEIVED_REACTION);
@@ -324,8 +340,10 @@ export class ProxySessionController {
324
340
  this.logger.warn({ error, messageId: message.messageId, contextKey: message.contextKey }, "Failed to acknowledge the incoming Feishu message with a reaction.");
325
341
  }
326
342
  await this.waitForCurrentSessionReset(message.contextKey);
327
- if (attachmentReservation && this.completeForwardAttachment(message, attachmentReservation)) {
328
- this.store.audit(message.contextKey, `${attachmentReservation.sourceKind}_attachment_coalesced`, {
343
+ if (this.recoveryClosed)
344
+ return;
345
+ if (attachmentReservation && this.completeReferenceInstruction(message, attachmentReservation)) {
346
+ this.store.audit(message.contextKey, "referenced_message_coalesced", {
329
347
  messageId: message.messageId,
330
348
  parentMessageId: message.parentMessageId,
331
349
  });
@@ -344,20 +362,22 @@ export class ProxySessionController {
344
362
  this.store.recordChatContext(chatContextKey, message.chatType);
345
363
  this.store.markChatActive(chatContextKey);
346
364
  }
347
- if (pendingResourceForward) {
348
- const resourceMessage = message;
349
- const attachment = await this.waitForForwardAttachment(message.messageId, pendingResourceForward, this.pendingResourceForwards);
365
+ if (pendingReference && !message.mergedForwardMessageId) {
366
+ const sourceMessage = message;
367
+ const attachment = await this.waitForReferenceInstruction(message.messageId, pendingReference);
368
+ if (this.recoveryClosed)
369
+ return;
350
370
  if (attachment) {
351
371
  coalescedForwardAttachment = true;
352
- const sourceText = resourceMessage.text.trim();
353
- const instruction = normalizeFeishuPostText(attachment.text)
354
- || (sourceText ? DEFAULT_REFERENCED_MESSAGE_INSTRUCTION : defaultResourcePrompt(resourceMessage));
355
- forwardedImages = (resourceMessage.images ?? []).map((image) => ({
356
- messageId: resourceMessage.messageId,
372
+ const sourceText = sourceMessage.text.trim();
373
+ const instruction = normalizeFeishuPostText(attachment.displayText ?? attachment.text)
374
+ || (sourceText ? DEFAULT_REFERENCED_MESSAGE_INSTRUCTION : defaultResourcePrompt(sourceMessage));
375
+ forwardedImages = (sourceMessage.images ?? []).map((image) => ({
376
+ messageId: sourceMessage.messageId,
357
377
  imageKey: image.imageKey,
358
378
  }));
359
- forwardedFiles = (resourceMessage.files ?? []).map((file) => ({
360
- messageId: resourceMessage.messageId,
379
+ forwardedFiles = (sourceMessage.files ?? []).map((file) => ({
380
+ messageId: sourceMessage.messageId,
361
381
  fileKey: file.fileKey,
362
382
  fileName: file.fileName,
363
383
  }));
@@ -366,18 +386,18 @@ export class ProxySessionController {
366
386
  text: sourceText ? referencedMessagePrompt(instruction, sourceText) : instruction,
367
387
  displayText: instruction,
368
388
  };
369
- this.relatedReactionMessageIds.set(message.messageId, [resourceMessage.messageId]);
389
+ this.relatedReactionMessageIds.set(message.messageId, [sourceMessage.messageId]);
370
390
  }
371
391
  }
372
392
  if (message.mergedForwardMessageId) {
373
393
  const mergedForwardMessage = message;
374
394
  const mergedForwardResult = this.outbound.readMergedForward(message.contextKey, message.mergedForwardMessageId).then((content) => ({ content }), (error) => ({ error }));
375
- const attachment = pendingMergedForward
376
- ? await this.waitForForwardAttachment(message.messageId, pendingMergedForward, this.pendingMergedForwards)
395
+ const attachment = pendingReference
396
+ ? await this.waitForReferenceInstruction(message.messageId, pendingReference)
377
397
  : undefined;
378
398
  if (attachment) {
379
399
  coalescedForwardAttachment = true;
380
- const instruction = normalizeFeishuPostText(attachment.text)
400
+ const instruction = normalizeFeishuPostText(attachment.displayText ?? attachment.text)
381
401
  || ((attachment.images?.length ?? 0) > 0 ? "请查看附带的内容。" : "请结合参考聊天记录处理。");
382
402
  message = {
383
403
  ...attachment,
@@ -387,6 +407,8 @@ export class ProxySessionController {
387
407
  this.relatedReactionMessageIds.set(message.messageId, [mergedForwardMessage.messageId]);
388
408
  }
389
409
  const result = await mergedForwardResult;
410
+ if (this.recoveryClosed)
411
+ return;
390
412
  if ("error" in result) {
391
413
  const replyTarget = message.replyInThread
392
414
  ? { messageId: message.messageId, replyInThread: true }
@@ -456,6 +478,9 @@ export class ProxySessionController {
456
478
  const fileCount = fileReferences.length;
457
479
  this.store.audit(message.contextKey, "incoming_message", {
458
480
  messageId: message.messageId,
481
+ eventId: message.eventId,
482
+ messageCreatedAt: message.createdAt,
483
+ receivedAt: message.receivedAt,
459
484
  text: message.text,
460
485
  ...(imageCount > 0 ? { imageCount } : {}),
461
486
  ...(fileCount > 0 ? { fileCount } : {}),
@@ -994,12 +1019,9 @@ export class ProxySessionController {
994
1019
  this.remoteTurnHistoryRefreshes.clear();
995
1020
  this.forkHistoryCursors.clear();
996
1021
  this.turnHistoryHydrations.clear();
997
- for (const pending of this.pendingMergedForwards.values())
1022
+ for (const pending of this.pendingMessageReferences.values())
998
1023
  pending.resolveAttachment(undefined);
999
- this.pendingMergedForwards.clear();
1000
- for (const pending of this.pendingResourceForwards.values())
1001
- pending.resolveAttachment(undefined);
1002
- this.pendingResourceForwards.clear();
1024
+ this.pendingMessageReferences.clear();
1003
1025
  this.relatedReactionMessageIds.clear();
1004
1026
  for (const timer of this.queuedPromptRetryTimers.values())
1005
1027
  clearTimeout(timer);
@@ -3132,6 +3154,16 @@ export class ProxySessionController {
3132
3154
  }
3133
3155
  async startTurn(loaded, text, replyTarget, localImagePaths, options = {}) {
3134
3156
  return this.settingsOperations.run(loaded.record.localSessionId, async () => {
3157
+ if (options.responseContinuationTurnId) {
3158
+ const current = this.store.getSession(loaded.record.localSessionId);
3159
+ if (!current || current.status === "closed" || current.lastTurnId !== options.responseContinuationTurnId
3160
+ || this.sessionResets.has(current.localSessionId)
3161
+ || this.responseContinuations.get(current.localSessionId)?.turnId !== options.responseContinuationTurnId
3162
+ || this.store.getPendingProviderSettings(current.localSessionId)) {
3163
+ throw new Error("任务或运行设置已变化,未自动恢复模型续接。请发送消息继续。");
3164
+ }
3165
+ return this.startTurnNow(loaded, text, replyTarget, localImagePaths, options);
3166
+ }
3135
3167
  try {
3136
3168
  await this.applyPendingProviderSettings(loaded);
3137
3169
  }
@@ -3196,15 +3228,26 @@ export class ProxySessionController {
3196
3228
  || this.sessionResets.has(loaded.record.localSessionId))) {
3197
3229
  throw new Error("方案执行已取消。");
3198
3230
  }
3199
- turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, options.approvedPlanTurnId
3200
- ? { text: runtimeText, collaborationMode: "default" }
3201
- : runtimePrompt(runtimeText, localImagePaths));
3231
+ if (options.responseContinuationTurnId) {
3232
+ const recovery = this.responseContinuations.get(loaded.record.localSessionId);
3233
+ if (recovery?.turnId !== options.responseContinuationTurnId
3234
+ || this.sessionResets.has(loaded.record.localSessionId) || !loaded.runtime.recoverResponseContinuation) {
3235
+ throw new Error("模型续接恢复已取消。");
3236
+ }
3237
+ turnId = await loaded.runtime.recoverResponseContinuation(loaded.record.localSessionId, options.responseContinuationTurnId, recovery.abort.signal);
3238
+ }
3239
+ else {
3240
+ turnId = await loaded.runtime.startTurn(loaded.record.localSessionId, options.approvedPlanTurnId
3241
+ ? { text: runtimeText, collaborationMode: "default" }
3242
+ : runtimePrompt(runtimeText, localImagePaths));
3243
+ }
3202
3244
  break;
3203
3245
  }
3204
3246
  catch (error) {
3205
3247
  const message = error instanceof Error ? error.message : String(error);
3206
3248
  const attempt = this.store.getTurnAttempt(attemptId);
3207
3249
  const retryAttempt = attempt
3250
+ && !options.responseContinuationTurnId
3208
3251
  && isRetryableLlmTurnFailure(message)
3209
3252
  && attempt.retryCount < MAX_LLM_TURN_RETRIES
3210
3253
  ? this.store.prepareUnstartedTurnAttemptRetry(attemptId)
@@ -3770,7 +3813,23 @@ export class ProxySessionController {
3770
3813
  if (event.type === "turn_failed") {
3771
3814
  if (this.retriedFailureTurnIds.has(event.turnId))
3772
3815
  return;
3773
- if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
3816
+ const source = this.store.getSession(event.sessionId);
3817
+ const responseContinuation = failedAttempt?.retryCount === 0 && failedAttempt.status !== "cancelling"
3818
+ && source && source.status !== "closed" && source.lastTurnId === event.turnId
3819
+ && !this.sessionResets.has(event.sessionId) && !this.store.getPendingProviderSettings(event.sessionId)
3820
+ && Boolean(this.runtimes.forAgent(source.agentName).recoverResponseContinuation)
3821
+ && isResponseContinuationError(event.message);
3822
+ if (failedAttempt && responseContinuation) {
3823
+ const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
3824
+ if (retryAttempt) {
3825
+ this.retriedFailureTurnIds.add(event.turnId);
3826
+ this.responseContinuations.set(event.sessionId, { turnId: event.turnId, abort: new AbortController() });
3827
+ await this.retryFailedLlmTurn(event, retryAttempt, true);
3828
+ return;
3829
+ }
3830
+ }
3831
+ if (failedAttempt && !this.responseContinuations.has(event.sessionId)
3832
+ && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
3774
3833
  const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
3775
3834
  if (retryAttempt) {
3776
3835
  this.retriedFailureTurnIds.add(event.turnId);
@@ -3923,7 +3982,7 @@ export class ProxySessionController {
3923
3982
  await this.scheduleNextQueuedPrompt(sessionId);
3924
3983
  }
3925
3984
  }
3926
- async retryFailedLlmTurn(event, attempt) {
3985
+ async retryFailedLlmTurn(event, attempt, responseContinuation = false) {
3927
3986
  const retryNumber = attempt.retryCount;
3928
3987
  const session = this.store.getSession(event.sessionId);
3929
3988
  if (!session || session.status === "closed") {
@@ -3939,13 +3998,16 @@ export class ProxySessionController {
3939
3998
  attemptId: attempt.attemptId,
3940
3999
  failedTurnId: event.turnId,
3941
4000
  retryNumber,
3942
- maxRetries: MAX_LLM_TURN_RETRIES,
4001
+ maxRetries: responseContinuation ? 1 : MAX_LLM_TURN_RETRIES,
4002
+ responseContinuation,
3943
4003
  error: event.message,
3944
4004
  });
3945
4005
  try {
3946
4006
  await this.outbound.onEvent({
3947
4007
  ...event,
3948
- message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
4008
+ message: responseContinuation
4009
+ ? `${event.message}\n\n模型续接状态失效,正在恢复连接并继续(1/1)。`
4010
+ : `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
3949
4011
  });
3950
4012
  }
3951
4013
  catch (error) {
@@ -3957,25 +4019,36 @@ export class ProxySessionController {
3957
4019
  try {
3958
4020
  const current = this.store.getSession(session.localSessionId) ?? session;
3959
4021
  const loaded = await this.loadSession(current, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
3960
- const retryTurnId = await this.startTurn(loaded, llmRetryRuntimePrompt(attempt.promptText, retryNumber), replyTarget, attempt.localImagePaths, {
4022
+ const retryTurnId = await this.startTurn(loaded, responseContinuation ? "" : llmRetryRuntimePrompt(attempt.promptText, retryNumber), replyTarget, responseContinuation ? undefined : attempt.localImagePaths, {
3961
4023
  attemptId: attempt.attemptId,
3962
4024
  messageId: attempt.messageId,
3963
- displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
4025
+ displayPrompt: responseContinuation ? `恢复模型续接:${attempt.promptText}` : llmRetryCardPrompt(attempt.promptText, retryNumber),
3964
4026
  preserveAttemptOnFailure: true,
4027
+ ...(responseContinuation ? { responseContinuationTurnId: event.turnId } : {}),
3965
4028
  });
3966
4029
  this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
3967
4030
  }
3968
4031
  catch (error) {
3969
- this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
3970
- this.store.updateSession(session.localSessionId, { status: "failed" });
3971
- await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError) => {
4032
+ const aborted = responseContinuation && !this.responseContinuations.has(session.localSessionId);
4033
+ this.store.updateTurnAttempt(attempt.attemptId, { status: aborted ? "cancelled" : "failed" });
4034
+ const latest = this.store.getSession(session.localSessionId);
4035
+ if (!responseContinuation || (latest?.lastTurnId === event.turnId && latest.status !== "closed")) {
4036
+ this.store.updateSession(session.localSessionId, { status: aborted ? "ready" : "failed" });
4037
+ }
4038
+ await this.finalizeTurnMessageReactions(event.turnId, aborted ? "cancelled" : "failed").catch((reactionError) => {
3972
4039
  this.logger.warn({ error: reactionError, sessionId: session.localSessionId, turnId: event.turnId }, "Failed to finalize reactions after an LLM retry could not start.");
3973
4040
  });
3974
4041
  this.logger.error({ error, sessionId: session.localSessionId, retryNumber }, "Failed to start an automatic LLM turn retry.");
4042
+ if (responseContinuation && !aborted) {
4043
+ await this.outbound.sendText(attempt.contextKey, `模型续接恢复未能启动:${displayRuntimeError(runtimeErrorMessage(error))}。请发送消息继续。`);
4044
+ }
3975
4045
  await this.scheduleNextQueuedPrompt(session.localSessionId);
3976
4046
  }
3977
4047
  finally {
3978
4048
  this.llmRetryingSessions.delete(session.localSessionId);
4049
+ if (this.responseContinuations.get(session.localSessionId)?.turnId === event.turnId) {
4050
+ this.responseContinuations.delete(session.localSessionId);
4051
+ }
3979
4052
  }
3980
4053
  }
3981
4054
  scheduleNextQueuedPrompt(sessionId) {
@@ -4090,7 +4163,7 @@ export class ProxySessionController {
4090
4163
  await this.finalizeTurnMessageReactions(turnId, session.lastTurnStatus);
4091
4164
  }
4092
4165
  }
4093
- registerPendingForwardAttachment(message, registry) {
4166
+ registerPendingReference(message) {
4094
4167
  let resolveAttachment;
4095
4168
  const attachmentPromise = new Promise((resolve) => {
4096
4169
  resolveAttachment = resolve;
@@ -4101,60 +4174,53 @@ export class ProxySessionController {
4101
4174
  attachmentPromise,
4102
4175
  resolveAttachment,
4103
4176
  };
4104
- registry.set(message.messageId, pending);
4177
+ this.pendingMessageReferences.set(message.messageId, pending);
4105
4178
  return pending;
4106
4179
  }
4107
- reserveForwardAttachment(message) {
4108
- if (!message.parentMessageId || isCommandMessage(message))
4180
+ reserveReferenceInstruction(message) {
4181
+ const parentMessageId = quotedMessageId(message);
4182
+ if (!parentMessageId || isCommandMessage(message))
4109
4183
  return undefined;
4110
- const candidates = [
4111
- { sourceKind: "merged_forward", registry: this.pendingMergedForwards },
4112
- { sourceKind: "resource", registry: this.pendingResourceForwards },
4113
- ];
4114
- for (const candidate of candidates) {
4115
- const pending = candidate.registry.get(message.parentMessageId);
4116
- if (!pending || pending.contextKey !== message.contextKey
4117
- || pending.userId !== message.userId || pending.attachmentMessageId)
4118
- continue;
4119
- pending.attachmentMessageId = message.messageId;
4120
- return {
4121
- sourceMessageId: message.parentMessageId,
4122
- sourceKind: candidate.sourceKind,
4123
- pending,
4124
- registry: candidate.registry,
4125
- };
4126
- }
4127
- return undefined;
4184
+ const pending = this.pendingMessageReferences.get(parentMessageId);
4185
+ if (!pending || pending.contextKey !== message.contextKey
4186
+ || pending.userId !== message.userId || pending.attachmentMessageId)
4187
+ return undefined;
4188
+ pending.attachmentMessageId = message.messageId;
4189
+ return { sourceMessageId: parentMessageId, pending };
4128
4190
  }
4129
- completeForwardAttachment(message, reservation) {
4130
- if (!message.parentMessageId)
4191
+ completeReferenceInstruction(message, reservation) {
4192
+ const parentMessageId = quotedMessageId(message);
4193
+ if (!parentMessageId || reservation.sourceMessageId !== parentMessageId)
4131
4194
  return false;
4132
- if (reservation.sourceMessageId !== message.parentMessageId)
4133
- return false;
4134
- if (reservation.registry.get(message.parentMessageId) !== reservation.pending)
4195
+ if (this.pendingMessageReferences.get(parentMessageId) !== reservation.pending)
4135
4196
  return false;
4136
4197
  if (reservation.pending.attachmentMessageId !== message.messageId)
4137
4198
  return false;
4138
4199
  reservation.pending.resolveAttachment(message);
4139
4200
  return true;
4140
4201
  }
4141
- async waitForForwardAttachment(messageId, pending, registry) {
4142
- const timer = setTimeout(() => {
4202
+ async waitForReferenceInstruction(messageId, pending) {
4203
+ const expire = () => {
4143
4204
  // Arrival reserves the pair before acknowledgement. A slow Reaction must not split it.
4144
4205
  if (pending.attachmentMessageId)
4145
4206
  return;
4146
- if (registry.get(messageId) === pending)
4147
- registry.delete(messageId);
4207
+ if (this.pendingMessageReferences.get(messageId) === pending)
4208
+ this.pendingMessageReferences.delete(messageId);
4148
4209
  pending.resolveAttachment(undefined);
4149
- }, FORWARD_ATTACHMENT_WINDOW_MS);
4150
- timer.unref?.();
4210
+ };
4211
+ const timer = this.referenceInstructionWindowMs > 0 ? setTimeout(expire, this.referenceInstructionWindowMs) : undefined;
4212
+ if (timer)
4213
+ timer.unref?.();
4214
+ else
4215
+ expire();
4151
4216
  try {
4152
4217
  return await pending.attachmentPromise;
4153
4218
  }
4154
4219
  finally {
4155
- clearTimeout(timer);
4156
- if (registry.get(messageId) === pending) {
4157
- registry.delete(messageId);
4220
+ if (timer)
4221
+ clearTimeout(timer);
4222
+ if (this.pendingMessageReferences.get(messageId) === pending) {
4223
+ this.pendingMessageReferences.delete(messageId);
4158
4224
  }
4159
4225
  }
4160
4226
  }
@@ -4213,6 +4279,8 @@ export class ProxySessionController {
4213
4279
  await this.cancelSession(record);
4214
4280
  }
4215
4281
  async cancelSession(record) {
4282
+ this.responseContinuations.get(record.localSessionId)?.abort.abort();
4283
+ this.responseContinuations.delete(record.localSessionId);
4216
4284
  this.planHandoffs.delete(record.localSessionId);
4217
4285
  const runtime = this.runtimes.forAgent(record.agentName);
4218
4286
  if (runtime.kind === "codex" && record.remoteSessionId && runtime.interruptRemoteTurn) {
@@ -4290,6 +4358,8 @@ export class ProxySessionController {
4290
4358
  });
4291
4359
  }
4292
4360
  async runSessionReset(sessionId, operation) {
4361
+ this.responseContinuations.get(sessionId)?.abort.abort();
4362
+ this.responseContinuations.delete(sessionId);
4293
4363
  this.planHandoffs.delete(sessionId);
4294
4364
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
4295
4365
  const reset = previous.catch(() => undefined).then(operation);
@@ -4671,9 +4741,16 @@ export class ProxySessionController {
4671
4741
  throw new Error("当前运行时不支持 Provider 设置。");
4672
4742
  loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
4673
4743
  const remoteSessionId = loaded.session.remoteSessionId;
4674
- const persist = (candidate) => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
4744
+ const persist = (candidate, replacement) => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
4675
4745
  if (candidate.remoteSessionId !== remoteSessionId) {
4676
- throw new Error("设置切换返回了不同的任务 ID,未保存设置;不允许替换任务或更改历史。");
4746
+ const saved = this.store.getSession(loaded.record.localSessionId);
4747
+ if (replacement?.emptyRemoteSessionId !== remoteSessionId || !saved || saved.remoteSessionId !== remoteSessionId
4748
+ || saved.status === "closed" || saved.status === "running" || saved.lastTurnId || loaded.session.activeTurnId || candidate.activeTurnId
4749
+ || candidate.localSessionId !== saved.localSessionId || !candidate.remoteSessionId
4750
+ || candidate.agentName !== saved.agentName || candidate.cwd !== saved.cwd || candidate.runtimeKind !== loaded.session.runtimeKind
4751
+ || this.store.hasTurnAttemptsForSession(saved.localSessionId) || this.store.getForkHistorySource(saved.localSessionId)) {
4752
+ throw new Error("设置切换返回了不同的任务 ID,未保存设置;仅允许已验证的空会话更换底层任务。");
4753
+ }
4677
4754
  }
4678
4755
  assertProviderSettingsApplied(candidate, settings);
4679
4756
  const agent = this.ensureAgent(loaded.record.agentName);
@@ -4681,6 +4758,7 @@ export class ProxySessionController {
4681
4758
  await this.persistAgentExecutionDefaultsNow(loaded.record.localSessionId, candidate);
4682
4759
  try {
4683
4760
  this.store.updateRuntimeSession(loaded.record.localSessionId, {
4761
+ remoteSessionId: candidate.remoteSessionId,
4684
4762
  modelProvider: candidate.modelProvider,
4685
4763
  model: candidate.model,
4686
4764
  reasoningEffort: candidate.reasoningEffort,
@@ -4694,6 +4772,7 @@ export class ProxySessionController {
4694
4772
  }
4695
4773
  });
4696
4774
  const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
4775
+ loaded.record = this.store.getSession(loaded.record.localSessionId) ?? loaded.record;
4697
4776
  this.settingsOnlySessions.delete(loaded.record.localSessionId);
4698
4777
  this.outbound.updateSessionModel(loaded.record.localSessionId, session.model, session.modelProvider);
4699
4778
  return session;
@@ -5858,6 +5937,7 @@ export class ProxySessionController {
5858
5937
  throw new Error(`这不是目录:${directory}`);
5859
5938
  }
5860
5939
  async createDirectoryFromCard(contextKey, action) {
5940
+ const updateMessageId = requiredCardMessageId(action.messageId);
5861
5941
  const directory = directoryActionPath(action.value.directory);
5862
5942
  await this.assertBrowsableDirectory(directory);
5863
5943
  const folderName = directoryFolderName(cardFormValue(action.value, "folderName"));
@@ -5873,9 +5953,9 @@ export class ProxySessionController {
5873
5953
  throw new Error(`目录已存在:${newDirectory}`);
5874
5954
  throw new Error(`无法创建目录 ${newDirectory}:${runtimeErrorMessage(error)}`);
5875
5955
  }
5876
- await this.openDirectoryBrowser(contextKey, directory, {
5877
- updateMessageId: requiredCardMessageId(action.messageId),
5878
- page: directoryPageValue(action.value.page),
5956
+ await this.openDirectoryBrowser(contextKey, newDirectory, {
5957
+ updateMessageId,
5958
+ page: 0,
5879
5959
  });
5880
5960
  await this.outbound.sendText(contextKey, `已创建目录:${newDirectory}`);
5881
5961
  }
@@ -8145,13 +8225,10 @@ function isCommandMessage(message) {
8145
8225
  const text = normalizeFeishuPostText(message.text).trimStart();
8146
8226
  return text.startsWith("/") || text.startsWith("!");
8147
8227
  }
8148
- function isForwardAttachmentSource(message) {
8149
- return !message.mergedForwardMessageId
8150
- && !message.parentMessageId
8228
+ function isReferenceSource(message) {
8229
+ return !quotedMessageId(message)
8151
8230
  && !message.contextKey.startsWith("console:")
8152
- && !isCommandMessage(message)
8153
- && (message.messageType === "post"
8154
- || (message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
8231
+ && !isCommandMessage(message);
8155
8232
  }
8156
8233
  function deduplicateImageReferences(images) {
8157
8234
  const seen = new Set();