@keyou007/agent-bot 0.1.14 → 0.1.15-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 (45) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +6 -4
  3. package/README.zh.md +6 -4
  4. package/dist/cli/help.js +6 -6
  5. package/dist/cli/initOptions.js +1 -4
  6. package/dist/cli/initOptions.js.map +1 -1
  7. package/dist/cli.js +4 -4
  8. package/dist/cli.js.map +1 -1
  9. package/dist/feishu/CardRenderer.js +40 -37
  10. package/dist/feishu/CardRenderer.js.map +1 -1
  11. package/dist/feishu/FeishuConnector.js +22 -7
  12. package/dist/feishu/FeishuConnector.js.map +1 -1
  13. package/dist/feishu/FeishuMessageClient.js +75 -0
  14. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  15. package/dist/feishu/FeishuTurnPresenter.js +23 -1
  16. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  17. package/dist/feishu/InboundText.js +13 -0
  18. package/dist/feishu/InboundText.js.map +1 -0
  19. package/dist/feishu/MergedForwardMessage.js +317 -0
  20. package/dist/feishu/MergedForwardMessage.js.map +1 -0
  21. package/dist/presentation/OutboundRouter.js +21 -0
  22. package/dist/presentation/OutboundRouter.js.map +1 -1
  23. package/dist/proxy/ProxySessionController.js +344 -22
  24. package/dist/proxy/ProxySessionController.js.map +1 -1
  25. package/dist/state/StateStore.js +35 -3
  26. package/dist/state/StateStore.js.map +1 -1
  27. package/dist/state/migrations.js +1 -0
  28. package/dist/state/migrations.js.map +1 -1
  29. package/npm-shrinkwrap.json +2 -2
  30. package/package.json +1 -1
  31. package/skills/agent-bot/SKILL.md +1 -1
  32. package/src/cli/help.ts +6 -6
  33. package/src/cli/initOptions.ts +1 -10
  34. package/src/cli.ts +4 -5
  35. package/src/feishu/CardRenderer.ts +41 -36
  36. package/src/feishu/FeishuConnector.ts +27 -7
  37. package/src/feishu/FeishuMessageClient.ts +110 -0
  38. package/src/feishu/FeishuTurnPresenter.ts +24 -3
  39. package/src/feishu/InboundText.ts +12 -0
  40. package/src/feishu/MergedForwardMessage.ts +372 -0
  41. package/src/feishu/types.ts +15 -0
  42. package/src/presentation/OutboundRouter.ts +28 -0
  43. package/src/proxy/ProxySessionController.ts +434 -20
  44. package/src/state/StateStore.ts +50 -2
  45. package/src/state/migrations.ts +1 -0
@@ -13,6 +13,7 @@ import type {
13
13
  ChatUpdatedEvent,
14
14
  IncomingMessage,
15
15
  MessageReplyTarget,
16
+ ReferencedMessageContent,
16
17
  } from "../feishu/types.js";
17
18
  import {
18
19
  CardRenderer,
@@ -26,6 +27,7 @@ import {
26
27
  type TaskListCardAction,
27
28
  } from "../feishu/CardRenderer.js";
28
29
  import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
30
+ import { normalizeFeishuPostText } from "../feishu/InboundText.js";
29
31
  import { allowsFeishuUser } from "../feishu/ownerAccess.js";
30
32
  import type { OutboundRouter } from "../presentation/OutboundRouter.js";
31
33
  import type { TurnActivity, TurnViewState } from "../presentation/turnViewTypes.js";
@@ -72,6 +74,20 @@ interface StartTurnOptions {
72
74
  preserveAttemptOnFailure?: boolean;
73
75
  }
74
76
 
77
+ interface PendingForwardAttachment {
78
+ contextKey: string;
79
+ attachmentMessageId?: string;
80
+ attachmentPromise: Promise<IncomingMessage | undefined>;
81
+ resolveAttachment: (message: IncomingMessage | undefined) => void;
82
+ }
83
+
84
+ interface ForwardAttachmentReservation {
85
+ sourceMessageId: string;
86
+ sourceKind: "merged_forward" | "resource";
87
+ pending: PendingForwardAttachment;
88
+ registry: Map<string, PendingForwardAttachment>;
89
+ }
90
+
75
91
  const MESSAGE_RECEIVED_REACTION = "OnIt";
76
92
  const MESSAGE_COMPLETED_REACTION = "DONE";
77
93
  const MESSAGE_FAILED_REACTION = "ERROR";
@@ -82,6 +98,9 @@ const RESET_HISTORY_PAGE_SIZE = 10;
82
98
  const MAX_LLM_TURN_RETRIES = 3;
83
99
  const RECOVERY_ACTIVITY_WINDOW_MS = 5 * 60 * 1_000;
84
100
  const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
101
+ const FORWARD_ATTACHMENT_WINDOW_MS = 800;
102
+ const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
103
+ const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
85
104
  const REMOTE_SESSION_REFERENCE_PREFIX = "agent-runtime:";
86
105
  const WINDOWS_DRIVES_DIRECTORY = "agentbot://windows-drives";
87
106
  const IMAGE_FILE_EXTENSIONS = new Set([
@@ -370,6 +389,9 @@ export class ProxySessionController {
370
389
  private readonly llmRetryingSessions = new Set<string>();
371
390
  private readonly retriedFailureTurnIds = new Set<string>();
372
391
  private readonly recoveryRetryTimers = new Map<string, NodeJS.Timeout>();
392
+ private readonly pendingMergedForwards = new Map<string, PendingForwardAttachment>();
393
+ private readonly pendingResourceForwards = new Map<string, PendingForwardAttachment>();
394
+ private readonly relatedReactionMessageIds = new Map<string, string[]>();
373
395
  private readonly unsubscribe: Array<() => void> = [];
374
396
  private readonly recoveryActivityHeartbeat: NodeJS.Timeout;
375
397
  private startupRecovery?: Promise<void>;
@@ -406,6 +428,9 @@ export class ProxySessionController {
406
428
  }
407
429
 
408
430
  async onMessage(message: IncomingMessage): Promise<void> {
431
+ let forwardedImages: Array<{ messageId: string; imageKey: string }> = [];
432
+ let forwardedFiles: Array<{ messageId: string; fileKey: string; fileName: string }> = [];
433
+ let coalescedForwardAttachment = false;
409
434
  if (!message.contextKey.startsWith("console:") && !allowsFeishuUser(this.config, message.userId)) {
410
435
  this.logger.debug(
411
436
  { messageId: message.messageId },
@@ -427,6 +452,15 @@ export class ProxySessionController {
427
452
  // For accepted messages, claim durable deduplication before acknowledgement so event
428
453
  // retries cannot add duplicate reactions.
429
454
  if (!this.store.claimInboundEvent(message.messageId, "message")) return;
455
+ const attachmentReservation = message.mergedForwardMessageId
456
+ ? undefined
457
+ : this.reserveForwardAttachment(message);
458
+ const pendingMergedForward = message.mergedForwardMessageId
459
+ ? this.registerPendingForwardAttachment(message, this.pendingMergedForwards)
460
+ : undefined;
461
+ const pendingResourceForward = !attachmentReservation && isStandaloneResourceMessage(message)
462
+ ? this.registerPendingForwardAttachment(message, this.pendingResourceForwards)
463
+ : undefined;
430
464
  try {
431
465
  const reactionId = await this.outbound.addReaction(
432
466
  message.contextKey,
@@ -442,6 +476,13 @@ export class ProxySessionController {
442
476
  "Failed to acknowledge the incoming Feishu message with a reaction.",
443
477
  );
444
478
  }
479
+ if (attachmentReservation && this.completeForwardAttachment(message, attachmentReservation)) {
480
+ this.store.audit(message.contextKey, `${attachmentReservation.sourceKind}_attachment_coalesced`, {
481
+ messageId: message.messageId,
482
+ parentMessageId: message.parentMessageId,
483
+ });
484
+ return;
485
+ }
445
486
  if (message.chatType === "p2p" && message.userId?.startsWith("ou_")) {
446
487
  try {
447
488
  await this.lifecycle?.rememberFeishuUserOpenId?.(message.userId);
@@ -457,20 +498,165 @@ export class ProxySessionController {
457
498
  this.store.recordChatContext(chatContextKey, message.chatType);
458
499
  this.store.markChatActive(chatContextKey);
459
500
  }
501
+ if (pendingResourceForward) {
502
+ const resourceMessage = message;
503
+ const attachment = await this.waitForForwardAttachment(
504
+ message.messageId,
505
+ pendingResourceForward,
506
+ this.pendingResourceForwards,
507
+ );
508
+ if (attachment) {
509
+ coalescedForwardAttachment = true;
510
+ const instruction = normalizeFeishuPostText(attachment.text) || defaultResourcePrompt(resourceMessage);
511
+ forwardedImages = (resourceMessage.images ?? []).map((image) => ({
512
+ messageId: resourceMessage.messageId,
513
+ imageKey: image.imageKey,
514
+ }));
515
+ forwardedFiles = (resourceMessage.files ?? []).map((file) => ({
516
+ messageId: resourceMessage.messageId,
517
+ fileKey: file.fileKey,
518
+ fileName: file.fileName,
519
+ }));
520
+ message = {
521
+ ...attachment,
522
+ text: instruction,
523
+ displayText: instruction,
524
+ };
525
+ this.relatedReactionMessageIds.set(message.messageId, [resourceMessage.messageId]);
526
+ }
527
+ }
528
+ if (message.mergedForwardMessageId) {
529
+ const mergedForwardMessage = message;
530
+ const mergedForwardResult = this.outbound.readMergedForward(
531
+ message.contextKey,
532
+ message.mergedForwardMessageId,
533
+ ).then(
534
+ (content) => ({ content } as const),
535
+ (error: unknown) => ({ error } as const),
536
+ );
537
+ const attachment = pendingMergedForward
538
+ ? await this.waitForForwardAttachment(
539
+ message.messageId,
540
+ pendingMergedForward,
541
+ this.pendingMergedForwards,
542
+ )
543
+ : undefined;
544
+ if (attachment) {
545
+ coalescedForwardAttachment = true;
546
+ const instruction = normalizeFeishuPostText(attachment.text)
547
+ || ((attachment.images?.length ?? 0) > 0 ? "请查看附带的内容。" : "请结合参考聊天记录处理。");
548
+ message = {
549
+ ...attachment,
550
+ text: instruction,
551
+ displayText: instruction,
552
+ };
553
+ this.relatedReactionMessageIds.set(message.messageId, [mergedForwardMessage.messageId]);
554
+ }
555
+ const result = await mergedForwardResult;
556
+ if ("error" in result) {
557
+ const replyTarget = message.replyInThread
558
+ ? { messageId: message.messageId, replyInThread: true as const }
559
+ : undefined;
560
+ await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
561
+ const detail = result.error instanceof Error ? result.error.message : String(result.error);
562
+ await this.outbound.withReplyTarget(
563
+ message.contextKey,
564
+ replyTarget,
565
+ () => this.sendError(message.contextKey, new Error(`无法读取合并转发消息:${detail}`)),
566
+ );
567
+ return;
568
+ }
569
+ const instruction = message.displayText ?? DEFAULT_MERGED_FORWARD_INSTRUCTION;
570
+ forwardedImages = result.content.images;
571
+ forwardedFiles = result.content.files;
572
+ message = {
573
+ ...message,
574
+ displayText: instruction,
575
+ text: mergedForwardPrompt(instruction, result.content.text),
576
+ };
577
+ }
578
+ const referencedMessageId = coalescedForwardAttachment ? undefined : quotedMessageId(message);
579
+ if (referencedMessageId) {
580
+ try {
581
+ const referenced = await this.resolveReferencedMessage(message.contextKey, referencedMessageId);
582
+ const instruction = normalizeFeishuPostText(message.displayText ?? message.text)
583
+ || DEFAULT_REFERENCED_MESSAGE_INSTRUCTION;
584
+ forwardedImages = [...referenced.images, ...forwardedImages];
585
+ forwardedFiles = [...referenced.files, ...forwardedFiles];
586
+ message = {
587
+ ...message,
588
+ displayText: instruction,
589
+ text: referencedMessagePrompt(instruction, referenced.text),
590
+ };
591
+ } catch (error) {
592
+ const replyTarget = message.replyInThread
593
+ ? { messageId: message.messageId, replyInThread: true as const }
594
+ : undefined;
595
+ await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
596
+ const detail = error instanceof Error ? error.message : String(error);
597
+ await this.outbound.withReplyTarget(
598
+ message.contextKey,
599
+ replyTarget,
600
+ () => this.sendError(message.contextKey, new Error(`无法读取引用消息:${detail}`)),
601
+ );
602
+ return;
603
+ }
604
+ }
460
605
  const replyTarget = message.replyInThread
461
606
  ? { messageId: message.messageId, replyInThread: true as const }
462
607
  : undefined;
463
- const imageCount = message.images?.length ?? 0;
608
+ const imageReferences = deduplicateImageReferences([
609
+ ...forwardedImages,
610
+ ...(message.images ?? []).map((image) => ({ messageId: message.messageId, imageKey: image.imageKey })),
611
+ ]);
612
+ const fileReferences = deduplicateFileReferences([
613
+ ...forwardedFiles,
614
+ ...(message.files ?? []).map((file) => ({
615
+ messageId: message.messageId,
616
+ fileKey: file.fileKey,
617
+ fileName: file.fileName,
618
+ })),
619
+ ]);
620
+ const imageCount = imageReferences.length;
621
+ const fileCount = fileReferences.length;
464
622
  this.store.audit(message.contextKey, "incoming_message", {
465
623
  messageId: message.messageId,
466
624
  text: message.text,
467
625
  ...(imageCount > 0 ? { imageCount } : {}),
626
+ ...(fileCount > 0 ? { fileCount } : {}),
468
627
  });
469
628
  let localImagePaths: string[] | undefined;
470
629
  if (imageCount > 0) {
471
630
  try {
472
- localImagePaths = await Promise.all(message.images!.map((image) =>
473
- this.outbound.downloadImage(message.contextKey, message.messageId, image.imageKey)));
631
+ localImagePaths = await Promise.all(imageReferences.map((image) =>
632
+ this.outbound.downloadImage(message.contextKey, image.messageId, image.imageKey)));
633
+ } catch (error) {
634
+ await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
635
+ await this.outbound.withReplyTarget(
636
+ message.contextKey,
637
+ replyTarget,
638
+ () => this.sendError(message.contextKey, error),
639
+ );
640
+ return;
641
+ }
642
+ }
643
+ if (fileCount > 0) {
644
+ try {
645
+ const downloadedFiles = await Promise.all(fileReferences.map(async (file) => ({
646
+ fileName: file.fileName,
647
+ filePath: await this.outbound.downloadFile(
648
+ message.contextKey,
649
+ file.messageId,
650
+ file.fileKey,
651
+ file.fileName,
652
+ ),
653
+ })));
654
+ const instruction = message.text.trim() || defaultResourcePromptFromCounts(imageCount, fileCount);
655
+ message = {
656
+ ...message,
657
+ displayText: message.displayText ?? instruction,
658
+ text: appendDownloadedFiles(instruction, downloadedFiles),
659
+ };
474
660
  } catch (error) {
475
661
  await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
476
662
  await this.outbound.withReplyTarget(
@@ -483,8 +669,9 @@ export class ProxySessionController {
483
669
  }
484
670
  let command: Command;
485
671
  try {
486
- command = imageCount > 0 && !message.text.trimStart().startsWith("/")
487
- ? { type: "prompt", text: message.text.trim() || "请查看这张图片" }
672
+ const resourceCount = imageCount + fileCount;
673
+ command = resourceCount > 0 && !message.text.trimStart().startsWith("/")
674
+ ? { type: "prompt", text: message.text.trim() || defaultResourcePromptFromCounts(imageCount, fileCount) }
488
675
  : this.router.parse(message.text);
489
676
  } catch (error) {
490
677
  await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
@@ -818,6 +1005,11 @@ export class ProxySessionController {
818
1005
  this.queuedPromptCards.clear();
819
1006
  this.queuedPromptCardWrites.clear();
820
1007
  this.sessionResets.clear();
1008
+ for (const pending of this.pendingMergedForwards.values()) pending.resolveAttachment(undefined);
1009
+ this.pendingMergedForwards.clear();
1010
+ for (const pending of this.pendingResourceForwards.values()) pending.resolveAttachment(undefined);
1011
+ this.pendingResourceForwards.clear();
1012
+ this.relatedReactionMessageIds.clear();
821
1013
  for (const timer of this.recoveryRetryTimers.values()) clearTimeout(timer);
822
1014
  this.recoveryRetryTimers.clear();
823
1015
  clearInterval(this.recoveryActivityHeartbeat);
@@ -1499,7 +1691,14 @@ export class ProxySessionController {
1499
1691
  await this.setTitle(contextKey, command.title);
1500
1692
  return;
1501
1693
  case "prompt":
1502
- await this.prompt(contextKey, command.text, messageId, replyTarget, localImagePaths);
1694
+ await this.prompt(
1695
+ contextKey,
1696
+ command.text,
1697
+ messageId,
1698
+ replyTarget,
1699
+ localImagePaths,
1700
+ incomingMessage?.displayText,
1701
+ );
1503
1702
  return;
1504
1703
  case "nosteer":
1505
1704
  await this.enqueueNoSteerPrompt(contextKey, command.text, messageId, replyTarget);
@@ -1986,6 +2185,7 @@ export class ProxySessionController {
1986
2185
  messageId?: string,
1987
2186
  replyTarget?: MessageReplyTarget,
1988
2187
  localImagePaths?: string[],
2188
+ displayPrompt?: string,
1989
2189
  ): Promise<void> {
1990
2190
  if (!text.trim()) throw new Error("请输入要交给 Agent 的内容。");
1991
2191
  let record = this.currentSession(contextKey);
@@ -1997,11 +2197,11 @@ export class ProxySessionController {
1997
2197
  this.inheritedNewTaskCwd(contextKey),
1998
2198
  false,
1999
2199
  true,
2000
- text,
2200
+ displayPrompt ?? text,
2001
2201
  replyTarget,
2002
2202
  );
2003
2203
  }
2004
- await this.promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths);
2204
+ await this.promptSession(record, contextKey, text, messageId, replyTarget, localImagePaths, displayPrompt);
2005
2205
  }
2006
2206
 
2007
2207
  private async promptSession(
@@ -2011,12 +2211,14 @@ export class ProxySessionController {
2011
2211
  messageId?: string,
2012
2212
  replyTarget?: MessageReplyTarget,
2013
2213
  localImagePaths?: string[],
2214
+ displayPrompt?: string,
2014
2215
  ): Promise<void> {
2015
2216
  if (this.llmRetryingSessions.has(record.localSessionId)) {
2016
2217
  const queued = this.persistQueuedPrompt(record.localSessionId, contextKey, text, {
2017
2218
  localImagePaths,
2018
2219
  messageId,
2019
2220
  replyTarget,
2221
+ displayPrompt,
2020
2222
  });
2021
2223
  this.store.audit(contextKey, "queued_prompt_added_during_llm_retry", {
2022
2224
  promptId: queued.promptId,
@@ -2034,7 +2236,13 @@ export class ProxySessionController {
2034
2236
  record.cwd,
2035
2237
  this.agentLabel(record.agentName),
2036
2238
  );
2037
- await this.outbound.startPendingTurn(record.localSessionId, contextKey, record.title, replyTarget, text);
2239
+ await this.outbound.startPendingTurn(
2240
+ record.localSessionId,
2241
+ contextKey,
2242
+ record.title,
2243
+ replyTarget,
2244
+ displayPrompt ?? text,
2245
+ );
2038
2246
  }
2039
2247
  let loaded: LoadedSession;
2040
2248
  try {
@@ -2065,12 +2273,13 @@ export class ProxySessionController {
2065
2273
  localImagePaths,
2066
2274
  messageId,
2067
2275
  replyTarget,
2276
+ displayPrompt,
2068
2277
  });
2069
2278
  return;
2070
2279
  }
2071
2280
  try {
2072
2281
  await loaded.runtime.steerTurn(record.localSessionId, activeTurnId, runtimePrompt(text, localImagePaths));
2073
- await this.presentSteerMessage(record.localSessionId, activeTurnId, text, messageId);
2282
+ await this.presentSteerMessage(record.localSessionId, activeTurnId, displayPrompt ?? text, messageId);
2074
2283
  if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, activeTurnId);
2075
2284
  return;
2076
2285
  } catch (error) {
@@ -2082,7 +2291,7 @@ export class ProxySessionController {
2082
2291
  this.logger.warn({ error: syncError, sessionId: record.localSessionId }, "Failed to synchronize after steering failure.");
2083
2292
  }
2084
2293
  if (!current?.activeTurnId) {
2085
- const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
2294
+ const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId, displayPrompt });
2086
2295
  if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
2087
2296
  return;
2088
2297
  }
@@ -2093,7 +2302,12 @@ export class ProxySessionController {
2093
2302
  current.activeTurnId,
2094
2303
  runtimePrompt(text, localImagePaths),
2095
2304
  );
2096
- await this.presentSteerMessage(record.localSessionId, current.activeTurnId, text, messageId);
2305
+ await this.presentSteerMessage(
2306
+ record.localSessionId,
2307
+ current.activeTurnId,
2308
+ displayPrompt ?? text,
2309
+ messageId,
2310
+ );
2097
2311
  if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, current.activeTurnId);
2098
2312
  return;
2099
2313
  } catch (retryError) {
@@ -2107,11 +2321,12 @@ export class ProxySessionController {
2107
2321
  localImagePaths,
2108
2322
  messageId,
2109
2323
  replyTarget,
2324
+ displayPrompt,
2110
2325
  });
2111
2326
  return;
2112
2327
  }
2113
2328
  }
2114
- const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId });
2329
+ const turnId = await this.startTurn(loaded, text, replyTarget, localImagePaths, { messageId, displayPrompt });
2115
2330
  if (messageId) await this.bindMessageReactionToTurn(messageId, record.localSessionId, turnId);
2116
2331
  }
2117
2332
 
@@ -2156,6 +2371,7 @@ export class ProxySessionController {
2156
2371
  localImagePaths?: string[];
2157
2372
  messageId?: string;
2158
2373
  replyTarget?: MessageReplyTarget;
2374
+ displayPrompt?: string;
2159
2375
  } = {},
2160
2376
  ): QueuedPromptRecord {
2161
2377
  return this.store.enqueuePrompt({
@@ -2166,6 +2382,7 @@ export class ProxySessionController {
2166
2382
  localImagePaths: options.localImagePaths,
2167
2383
  messageId: options.messageId,
2168
2384
  replyMessageId: options.replyTarget?.messageId,
2385
+ displayPrompt: options.displayPrompt,
2169
2386
  });
2170
2387
  }
2171
2388
 
@@ -2227,7 +2444,7 @@ export class ProxySessionController {
2227
2444
  contextKey,
2228
2445
  prompts: this.store.listQueuedPrompts(localSessionId).map((prompt) => ({
2229
2446
  id: prompt.promptId,
2230
- text: prompt.text,
2447
+ text: prompt.displayPrompt ?? prompt.text,
2231
2448
  })),
2232
2449
  });
2233
2450
  }
@@ -2729,7 +2946,7 @@ export class ProxySessionController {
2729
2946
  options: StartTurnOptions = {},
2730
2947
  ): Promise<string> {
2731
2948
  const currentRecord = this.store.getSession(loaded.record.localSessionId);
2732
- const title = currentRecord?.title ?? normalizeTaskTitle(text);
2949
+ const title = currentRecord?.title ?? normalizeTaskTitle(options.displayPrompt ?? text);
2733
2950
  if (!currentRecord?.title && title) this.store.updateRuntimeSession(loaded.record.localSessionId, { title });
2734
2951
  if (title) this.outbound.updateSessionTitle(loaded.record.localSessionId, title);
2735
2952
  const attemptId = options.attemptId ?? createId("attempt");
@@ -3392,7 +3609,7 @@ export class ProxySessionController {
3392
3609
  prompt.text,
3393
3610
  prompt.replyMessageId ? { messageId: prompt.replyMessageId, replyInThread: true } : undefined,
3394
3611
  prompt.localImagePaths,
3395
- { messageId: prompt.messageId },
3612
+ { messageId: prompt.messageId, displayPrompt: prompt.displayPrompt },
3396
3613
  );
3397
3614
  if (prompt.messageId) await this.bindMessageReactionToTurn(prompt.messageId, sessionId, turnId);
3398
3615
  } catch (error) {
@@ -3418,13 +3635,17 @@ export class ProxySessionController {
3418
3635
  messageId: string,
3419
3636
  status: "completed" | "failed" | "cancelled",
3420
3637
  ): Promise<void> {
3421
- const reaction = this.store.claimMessageReaction(messageId);
3422
- if (reaction) await this.replaceMessageReaction(reaction, status);
3638
+ for (const relatedMessageId of this.takeRelatedReactionMessageIds(messageId)) {
3639
+ const reaction = this.store.claimMessageReaction(relatedMessageId);
3640
+ if (reaction) await this.replaceMessageReaction(reaction, status);
3641
+ }
3423
3642
  }
3424
3643
 
3425
3644
  private async bindMessageReactionToTurn(messageId: string, sessionId: string, turnId: string): Promise<void> {
3426
- this.store.bindMessageToTurn(messageId, sessionId, turnId);
3427
- this.store.bindMessageReaction(messageId, sessionId, turnId);
3645
+ for (const relatedMessageId of this.takeRelatedReactionMessageIds(messageId)) {
3646
+ this.store.bindMessageToTurn(relatedMessageId, sessionId, turnId);
3647
+ this.store.bindMessageReaction(relatedMessageId, sessionId, turnId);
3648
+ }
3428
3649
  const session = this.store.getSession(sessionId);
3429
3650
  if (session?.lastTurnId !== turnId) return;
3430
3651
  if (session.lastTurnStatus === "completed" || session.lastTurnStatus === "failed" || session.lastTurnStatus === "cancelled") {
@@ -3432,6 +3653,101 @@ export class ProxySessionController {
3432
3653
  }
3433
3654
  }
3434
3655
 
3656
+ private registerPendingForwardAttachment(
3657
+ message: IncomingMessage,
3658
+ registry: Map<string, PendingForwardAttachment>,
3659
+ ): PendingForwardAttachment {
3660
+ let resolveAttachment!: (message: IncomingMessage | undefined) => void;
3661
+ const attachmentPromise = new Promise<IncomingMessage | undefined>((resolve) => {
3662
+ resolveAttachment = resolve;
3663
+ });
3664
+ const pending = {
3665
+ contextKey: message.contextKey,
3666
+ attachmentPromise,
3667
+ resolveAttachment,
3668
+ };
3669
+ registry.set(message.messageId, pending);
3670
+ return pending;
3671
+ }
3672
+
3673
+ private reserveForwardAttachment(message: IncomingMessage): ForwardAttachmentReservation | undefined {
3674
+ if (!message.parentMessageId) return undefined;
3675
+ const candidates = [
3676
+ { sourceKind: "merged_forward" as const, registry: this.pendingMergedForwards },
3677
+ { sourceKind: "resource" as const, registry: this.pendingResourceForwards },
3678
+ ];
3679
+ for (const candidate of candidates) {
3680
+ const pending = candidate.registry.get(message.parentMessageId);
3681
+ if (!pending || pending.contextKey !== message.contextKey || pending.attachmentMessageId) continue;
3682
+ pending.attachmentMessageId = message.messageId;
3683
+ return {
3684
+ sourceMessageId: message.parentMessageId,
3685
+ sourceKind: candidate.sourceKind,
3686
+ pending,
3687
+ registry: candidate.registry,
3688
+ };
3689
+ }
3690
+ return undefined;
3691
+ }
3692
+
3693
+ private completeForwardAttachment(
3694
+ message: IncomingMessage,
3695
+ reservation: ForwardAttachmentReservation,
3696
+ ): boolean {
3697
+ if (!message.parentMessageId) return false;
3698
+ if (reservation.sourceMessageId !== message.parentMessageId) return false;
3699
+ if (reservation.registry.get(message.parentMessageId) !== reservation.pending) return false;
3700
+ if (reservation.pending.attachmentMessageId !== message.messageId) return false;
3701
+ reservation.pending.resolveAttachment(message);
3702
+ return true;
3703
+ }
3704
+
3705
+ private async waitForForwardAttachment(
3706
+ messageId: string,
3707
+ pending: PendingForwardAttachment,
3708
+ registry: Map<string, PendingForwardAttachment>,
3709
+ ): Promise<IncomingMessage | undefined> {
3710
+ let timer: NodeJS.Timeout | undefined;
3711
+ const timeout = new Promise<undefined>((resolve) => {
3712
+ timer = setTimeout(() => resolve(undefined), FORWARD_ATTACHMENT_WINDOW_MS);
3713
+ timer.unref?.();
3714
+ });
3715
+ try {
3716
+ return await Promise.race([pending.attachmentPromise, timeout]);
3717
+ } finally {
3718
+ if (timer) clearTimeout(timer);
3719
+ if (registry.get(messageId) === pending) {
3720
+ registry.delete(messageId);
3721
+ }
3722
+ }
3723
+ }
3724
+
3725
+ private async resolveReferencedMessage(
3726
+ contextKey: string,
3727
+ messageId: string,
3728
+ ): Promise<ReferencedMessageContent> {
3729
+ const local = this.store.findAgentBotTurnMessageById(messageId);
3730
+ if (local) {
3731
+ const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(local.turnId));
3732
+ const text = snapshot ? renderLocalTurnReference(snapshot, local.messageKind) : undefined;
3733
+ if (text) {
3734
+ return {
3735
+ text,
3736
+ messageType: local.messageKind === "final" ? "agent_bot_reply" : "agent_bot_progress",
3737
+ images: [],
3738
+ files: [],
3739
+ };
3740
+ }
3741
+ }
3742
+ return this.outbound.readReferencedMessage(contextKey, messageId);
3743
+ }
3744
+
3745
+ private takeRelatedReactionMessageIds(messageId: string): string[] {
3746
+ const related = this.relatedReactionMessageIds.get(messageId) ?? [];
3747
+ this.relatedReactionMessageIds.delete(messageId);
3748
+ return [...new Set([messageId, ...related])];
3749
+ }
3750
+
3435
3751
  private async finalizeTurnMessageReactions(
3436
3752
  turnId: string,
3437
3753
  status: "completed" | "failed" | "cancelled",
@@ -6042,6 +6358,104 @@ function runtimePrompt(text: string, localImagePaths?: string[]): RuntimePrompt
6042
6358
  return localImagePaths?.length ? { text, localImagePaths } : text;
6043
6359
  }
6044
6360
 
6361
+ function mergedForwardPrompt(
6362
+ instruction: string,
6363
+ transcript: string,
6364
+ ): string {
6365
+ return `${instruction}\n\n参考聊天记录:\n${transcript}`;
6366
+ }
6367
+
6368
+ function referencedMessagePrompt(instruction: string, referencedMessage: string): string {
6369
+ return `${instruction}\n\n引用消息:\n${referencedMessage}`;
6370
+ }
6371
+
6372
+ function quotedMessageId(message: IncomingMessage): string | undefined {
6373
+ const messageId = message.parentMessageId?.trim();
6374
+ if (!messageId || message.contextKey.startsWith("console:")) return undefined;
6375
+ if (message.mergedForwardMessageId) return undefined;
6376
+ const text = message.text.trimStart();
6377
+ if (text.startsWith("/") || text.startsWith("!")) return undefined;
6378
+ if (message.threadContext && message.rootMessageId === messageId) return undefined;
6379
+ return messageId;
6380
+ }
6381
+
6382
+ function renderLocalTurnReference(
6383
+ snapshot: TurnViewState,
6384
+ messageKind: "progress" | "final",
6385
+ ): string | undefined {
6386
+ if (messageKind === "final") {
6387
+ const response = snapshot.finalResponse?.trim() || snapshot.assistantText?.trim();
6388
+ return response
6389
+ ? `[消息类型:AgentBot 回复]\n${truncateMiddle(response, 48_000)}`
6390
+ : undefined;
6391
+ }
6392
+
6393
+ const activityText = (snapshot.activities ?? [])
6394
+ .filter((activity): activity is Extract<TurnActivity, { kind: "assistant" | "reasoning" }> =>
6395
+ activity.kind === "assistant" || activity.kind === "reasoning")
6396
+ .map((activity) => activity.text.trim())
6397
+ .filter(Boolean)
6398
+ .join("\n\n");
6399
+ const content = [
6400
+ snapshot.prompt?.trim() ? `用户请求:\n${snapshot.prompt.trim()}` : "",
6401
+ activityText,
6402
+ snapshot.progressText?.trim() || "",
6403
+ snapshot.finalResponse?.trim() || snapshot.assistantText?.trim() || "",
6404
+ snapshot.error?.trim() ? `错误:${snapshot.error.trim()}` : "",
6405
+ ].filter(Boolean).join("\n\n");
6406
+ return content
6407
+ ? `[消息类型:AgentBot 思考卡片]\n${truncateMiddle(content, 48_000)}`
6408
+ : undefined;
6409
+ }
6410
+
6411
+ function appendDownloadedFiles(
6412
+ prompt: string,
6413
+ files: Array<{ fileName: string; filePath: string }>,
6414
+ ): string {
6415
+ return `${prompt}\n\n参考文件(已下载到本地):\n${files.map((file, index) =>
6416
+ `[文件 ${index + 1}:${file.fileName}] ${file.filePath}`).join("\n")}`;
6417
+ }
6418
+
6419
+ function defaultResourcePrompt(message: IncomingMessage): string {
6420
+ return defaultResourcePromptFromCounts(message.images?.length ?? 0, message.files?.length ?? 0);
6421
+ }
6422
+
6423
+ function defaultResourcePromptFromCounts(imageCount: number, fileCount: number): string {
6424
+ if (imageCount > 0 && fileCount > 0) return "请查看附带的图片和文件";
6425
+ if (fileCount > 0) return fileCount === 1 ? "请查看这个文件" : "请查看这些文件";
6426
+ return imageCount === 1 ? "请查看这张图片" : "请查看这些图片";
6427
+ }
6428
+
6429
+ function isStandaloneResourceMessage(message: IncomingMessage): boolean {
6430
+ return !message.mergedForwardMessageId
6431
+ && message.text.trim().length === 0
6432
+ && ((message.images?.length ?? 0) > 0 || (message.files?.length ?? 0) > 0);
6433
+ }
6434
+
6435
+ function deduplicateImageReferences(
6436
+ images: Array<{ messageId: string; imageKey: string }>,
6437
+ ): Array<{ messageId: string; imageKey: string }> {
6438
+ const seen = new Set<string>();
6439
+ return images.filter((image) => {
6440
+ const key = `${image.messageId}:${image.imageKey}`;
6441
+ if (seen.has(key)) return false;
6442
+ seen.add(key);
6443
+ return true;
6444
+ });
6445
+ }
6446
+
6447
+ function deduplicateFileReferences(
6448
+ files: Array<{ messageId: string; fileKey: string; fileName: string }>,
6449
+ ): Array<{ messageId: string; fileKey: string; fileName: string }> {
6450
+ const seen = new Set<string>();
6451
+ return files.filter((file) => {
6452
+ const key = `${file.messageId}:${file.fileKey}`;
6453
+ if (seen.has(key)) return false;
6454
+ seen.add(key);
6455
+ return true;
6456
+ });
6457
+ }
6458
+
6045
6459
  function recoveryCardPrompt(prompt: string): string {
6046
6460
  return `恢复重启前的任务:${prompt}`;
6047
6461
  }