@keyou007/agent-bot 0.1.23 → 0.1.24-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 (48) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +14 -0
  3. package/README.zh.md +14 -0
  4. package/dist/cli/controlProtocol.js.map +1 -1
  5. package/dist/cli/help.js +4 -0
  6. package/dist/cli/help.js.map +1 -1
  7. package/dist/cli/taskGroupOptions.js +27 -0
  8. package/dist/cli/taskGroupOptions.js.map +1 -1
  9. package/dist/cli.js +21 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/codex/CodexProcessManager.js +19 -3
  12. package/dist/codex/CodexProcessManager.js.map +1 -1
  13. package/dist/codex/CodexRuntime.js +92 -9
  14. package/dist/codex/CodexRuntime.js.map +1 -1
  15. package/dist/commands/CommandRouter.js +27 -0
  16. package/dist/commands/CommandRouter.js.map +1 -1
  17. package/dist/feishu/FeishuMessageClient.js +4 -1
  18. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  19. package/dist/feishu/MergedForwardMessage.js +17 -7
  20. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  21. package/dist/index.js +6 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/proxy/ConversationExport.js +41 -0
  24. package/dist/proxy/ConversationExport.js.map +1 -0
  25. package/dist/proxy/ProxySessionController.js +106 -5
  26. package/dist/proxy/ProxySessionController.js.map +1 -1
  27. package/dist/state/StateStore.js +40 -0
  28. package/dist/state/StateStore.js.map +1 -1
  29. package/docs/technical-reference.md +4 -0
  30. package/docs/technical-reference.zh.md +4 -0
  31. package/npm-shrinkwrap.json +2 -2
  32. package/package.json +1 -1
  33. package/skills/agent-bot/SKILL.md +9 -0
  34. package/src/cli/controlProtocol.ts +9 -0
  35. package/src/cli/help.ts +4 -0
  36. package/src/cli/taskGroupOptions.ts +33 -3
  37. package/src/cli.ts +21 -0
  38. package/src/codex/CodexProcessManager.ts +21 -4
  39. package/src/codex/CodexRuntime.ts +104 -12
  40. package/src/commands/CommandRouter.ts +24 -0
  41. package/src/commands/commandTypes.ts +1 -0
  42. package/src/feishu/FeishuMessageClient.ts +4 -1
  43. package/src/feishu/MergedForwardMessage.ts +29 -7
  44. package/src/index.ts +9 -0
  45. package/src/proxy/ConversationExport.ts +43 -0
  46. package/src/proxy/ProxySessionController.ts +152 -6
  47. package/src/runtime/types.ts +6 -0
  48. package/src/state/StateStore.ts +38 -0
@@ -5,6 +5,7 @@ import type {
5
5
  AgentProcessInfo,
6
6
  ApprovalDecision,
7
7
  CreateRuntimeSessionInput,
8
+ ConversationTurn,
8
9
  ForkRuntimeSessionInput,
9
10
  ModelOption,
10
11
  ModelProviderOption,
@@ -383,6 +384,46 @@ export class CodexRuntime implements AgentRuntime {
383
384
  };
384
385
  }
385
386
 
387
+ async *readConversation(remoteSessionId: string, throughTurnId?: string): AsyncIterable<ConversationTurn> {
388
+ const client = await this.client();
389
+ const seenCursors = new Set<string>();
390
+ let cursor: string | undefined;
391
+ const boundary = throughTurnId
392
+ ?? (await this.listRecentThreadTurnsThroughCompleted(client, remoteSessionId))
393
+ .find((turn) => turn.status === "completed")?.id;
394
+ if (!boundary) return;
395
+ do {
396
+ const page = await this.readTurnPage(client, remoteSessionId, {
397
+ cursor, limit: 50, itemsView: "summary", sortDirection: "asc",
398
+ }, CONTROL_REQUEST_TIMEOUT_MS);
399
+ for (const turn of page.data) {
400
+ if (turn.status === "inProgress" || (turn.id === boundary && turn.status !== "completed")) {
401
+ throw new Error("上下文历史发生变化,请重试。");
402
+ }
403
+ if (turn.status !== "completed") continue;
404
+ const messages: ConversationTurn["messages"] = [];
405
+ for (const item of turn.items ?? []) {
406
+ if (item.type === "userMessage") {
407
+ const text = (item.content ?? []).filter((block) => block.type === "text")
408
+ .map((block) => block.text ?? "").join("\n");
409
+ if (text.trim()) messages.push({ role: "user", text });
410
+ } else if (item.type === "agentMessage" && item.phase !== "commentary" && item.text?.trim()) {
411
+ messages.push({ role: "assistant", text: item.text });
412
+ }
413
+ }
414
+ yield { turnId: turn.id, messages };
415
+ if (turn.id === boundary) return;
416
+ }
417
+ const nextCursor = page.nextCursor ?? undefined;
418
+ if (nextCursor && (nextCursor === cursor || seenCursors.has(nextCursor))) {
419
+ throw new Error("App Server repeated a conversation history cursor.");
420
+ }
421
+ if (nextCursor) seenCursors.add(nextCursor);
422
+ cursor = nextCursor;
423
+ } while (cursor);
424
+ throw new Error("找不到上下文截止轮次,未创建克隆任务。");
425
+ }
426
+
386
427
  async readRemoteForkSource(remoteSessionId: string): Promise<RemoteSessionSummary> {
387
428
  const client = await this.client();
388
429
  const metadata = await client.request<ThreadReadResponse>(
@@ -454,14 +495,14 @@ export class CodexRuntime implements AgentRuntime {
454
495
  private async readTurnPage(
455
496
  client: AppServerClient,
456
497
  remoteSessionId: string,
457
- input: { cursor?: string; limit: number; itemsView: "summary" | "full" },
498
+ input: { cursor?: string; limit: number; itemsView: "summary" | "full"; sortDirection?: "asc" | "desc" },
458
499
  timeoutMs = SYNC_REQUEST_TIMEOUT_MS,
459
500
  ): Promise<ThreadTurnsListResponse> {
460
501
  try {
461
502
  return await client.request<ThreadTurnsListResponse>("thread/turns/list", {
462
503
  threadId: remoteSessionId,
463
504
  ...(input.cursor ? { cursor: input.cursor } : {}),
464
- limit: input.limit, sortDirection: "desc", itemsView: input.itemsView,
505
+ limit: input.limit, sortDirection: input.sortDirection ?? "desc", itemsView: input.itemsView,
465
506
  }, timeoutMs);
466
507
  } catch (error) {
467
508
  if (error instanceof AppServerRequestError && error.method === "thread/turns/list"
@@ -674,17 +715,38 @@ export class CodexRuntime implements AgentRuntime {
674
715
  await this.detachThreadForSettings(client, session);
675
716
  detached = true;
676
717
  }
677
- const response = empty
678
- ? await client.request<ThreadResponse>("thread/start", {
679
- ...params, threadSource: "user", allowProviderModelFallback: false,
680
- }, SESSION_REQUEST_TIMEOUT_MS)
681
- : await client.request<ThreadResponse>("thread/resume", {
682
- ...params, threadId: session.remoteSessionId, excludeTurns: true,
683
- }, SESSION_REQUEST_TIMEOUT_MS);
684
- if (empty) replacementId = response.thread.id;
718
+ let response: ThreadResponse;
719
+ if (empty) {
720
+ response = await client.request<ThreadResponse>("thread/start", {
721
+ ...params, threadSource: "user", allowProviderModelFallback: false,
722
+ }, SESSION_REQUEST_TIMEOUT_MS);
723
+ replacementId = response.thread.id;
724
+ } else {
725
+ response = await client.request<ThreadResponse>("thread/resume", {
726
+ ...params, threadId: session.remoteSessionId, excludeTurns: true,
727
+ }, SESSION_REQUEST_TIMEOUT_MS);
728
+ if (!providerSettingsMatch(response, settings)) {
729
+ this.logger.warn(
730
+ {
731
+ sessionId,
732
+ threadId: session.remoteSessionId,
733
+ requestedProvider: settings.modelProvider,
734
+ actualProvider: response.modelProvider,
735
+ requestedModel: settings.model,
736
+ actualModel: response.model,
737
+ },
738
+ "App Server did not apply Provider settings while resuming; forking the latest completed Turn.",
739
+ );
740
+ await this.detachThreadForSettings(client, session);
741
+ response = await this.forkThreadForSettings(client, session, settings);
742
+ replacementId = response.thread.id;
743
+ }
744
+ }
685
745
  assertProviderSettingsApplied(response, settings);
686
- if (!empty && response.thread.id !== session.remoteSessionId) throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
687
- if (empty && session.title) await client.request("thread/name/set", {
746
+ if (!empty && !replacementId && response.thread.id !== session.remoteSessionId) {
747
+ throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
748
+ }
749
+ if (session.title && (empty || replacementId)) await client.request("thread/name/set", {
688
750
  threadId: response.thread.id, name: session.title,
689
751
  }, SESSION_REQUEST_TIMEOUT_MS);
690
752
  const candidate = { ...session, ...settings, remoteSessionId: response.thread.id,
@@ -734,6 +796,29 @@ export class CodexRuntime implements AgentRuntime {
734
796
  }
735
797
  }
736
798
 
799
+ private async forkThreadForSettings(
800
+ client: AppServerClient,
801
+ session: CodexSession,
802
+ settings: RuntimeExecutionSettings,
803
+ ): Promise<ThreadResponse> {
804
+ const turns = await this.listRecentThreadTurnsThroughCompleted(client, session.remoteSessionId);
805
+ const lastTurnId = turns.find((turn) => turn.status === "completed")?.id;
806
+ if (!lastTurnId) {
807
+ throw new Error("无法为 Provider 切换找到已完成的 Turn。请先完成一轮任务后重试。");
808
+ }
809
+ return client.request<ThreadResponse>("thread/fork", {
810
+ threadId: session.remoteSessionId,
811
+ lastTurnId,
812
+ cwd: session.cwd,
813
+ model: settings.model,
814
+ modelProvider: settings.modelProvider,
815
+ threadSource: "user",
816
+ excludeTurns: true,
817
+ ...threadLifecycleParams(session.cwd),
818
+ ...permissionParams(settings.permissionMode),
819
+ }, FORK_REQUEST_TIMEOUT_MS);
820
+ }
821
+
737
822
  private async discardUnusedThread(client: AppServerClient, threadId: string): Promise<void> {
738
823
  try {
739
824
  await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
@@ -1688,6 +1773,13 @@ function remoteThreadStatus(value: string | undefined): RemoteSessionSummary["st
1688
1773
  return "not_loaded";
1689
1774
  }
1690
1775
 
1776
+ function providerSettingsMatch(
1777
+ response: Pick<ThreadResponse, "modelProvider" | "model">,
1778
+ settings: RuntimeExecutionSettings,
1779
+ ): boolean {
1780
+ return response.modelProvider === settings.modelProvider && response.model === settings.model;
1781
+ }
1782
+
1691
1783
  function codexSourceLabel(source: unknown): string {
1692
1784
  if (typeof source === "string") return source;
1693
1785
  if (isRecord(source)) {
@@ -3,6 +3,8 @@ import type { Command } from "./commandTypes.js";
3
3
  export const COMMAND_NAMES = [
4
4
  "agent",
5
5
  "archive",
6
+ "clone",
7
+ "clonegroup",
6
8
  "dir",
7
9
  "dismiss",
8
10
  "file",
@@ -34,6 +36,7 @@ type CommandName = (typeof COMMAND_NAMES)[number];
34
36
  const COMMAND_INITIALISMS: Partial<Record<CommandName, string>> = {
35
37
  dir: "di",
36
38
  forkgroup: "fg",
39
+ clonegroup: "cg",
37
40
  newgroup: "ng",
38
41
  nosteer: "ns",
39
42
  };
@@ -67,6 +70,9 @@ export class CommandRouter {
67
70
  return parseNewCommand(args);
68
71
  case "newgroup":
69
72
  return parseNewGroupCommand(args);
73
+ case "clone":
74
+ case "clonegroup":
75
+ return parseCloneCommand(command, args);
70
76
  case "forkgroup":
71
77
  return parseForkGroupCommand(args);
72
78
  case "fork":
@@ -242,6 +248,24 @@ function parseNewTaskOptions(
242
248
  };
243
249
  }
244
250
 
251
+ function parseCloneCommand(type: "clone" | "clonegroup", args: string[]): Command {
252
+ const title: string[] = [];
253
+ let agentName: string | undefined;
254
+ for (let index = 0; index < args.length; index += 1) {
255
+ const argument = args[index]!;
256
+ if (argument === "--agent") {
257
+ if (agentName !== undefined) throw new Error(`/${type} 只能指定一次 --agent。`);
258
+ const name = args[++index];
259
+ if (!name?.trim() || name.startsWith("--")) throw new Error("请在 --agent 后指定 Agent 名称。");
260
+ agentName = name.trim();
261
+ } else {
262
+ if (argument.startsWith("--")) throw new Error(`/${type} 不支持参数:${argument}。`);
263
+ title.push(argument);
264
+ }
265
+ }
266
+ return { type, title: title.join(" ").trim() || undefined, agentName };
267
+ }
268
+
245
269
  function parseForkCommand(args: string[]): Extract<Command, { type: "fork" }> {
246
270
  rejectOptions("/fork", args);
247
271
  if (args.length > 1) throw new Error("/fork 只接受一个可选的任务序号或任务 ID。");
@@ -4,6 +4,7 @@ export type Command =
4
4
  | { type: "newgroup"; title?: string; cwd?: string; projectless?: boolean }
5
5
  | { type: "dir"; directory?: string }
6
6
  | { type: "file"; filePath: string }
7
+ | { type: "clone" | "clonegroup"; title?: string; agentName?: string }
7
8
  | { type: "forkgroup"; title?: string }
8
9
  | { type: "fork"; sessionId?: string }
9
10
  | { type: "title"; title: string }
@@ -259,8 +259,11 @@ export class FeishuMessageClient implements FeishuOutbound {
259
259
  private async readMessageItems(messageId: string, operation: string): Promise<MergedForwardMessageItem[]> {
260
260
  try {
261
261
  const token = await this.getTenantAccessToken();
262
+ const url = new URL(`https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`);
263
+ // The default compatibility format can replace Card 2.0 content with an upgrade notice.
264
+ url.searchParams.set("card_msg_content_type", "user_card_content");
262
265
  const response = await fetch(
263
- `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`,
266
+ url,
264
267
  { headers: { Authorization: `Bearer ${token}` } },
265
268
  );
266
269
  const payload = (await response.json()) as GetMessageResponse;
@@ -187,7 +187,7 @@ function renderMessageContent(
187
187
  case "sticker":
188
188
  return "[表情包]";
189
189
  case "interactive":
190
- return renderInteractiveCard(content) || "[消息卡片]";
190
+ return renderInteractiveCard(content, imageLabel) || "[消息卡片]";
191
191
  case "share_chat":
192
192
  return "[群名片]";
193
193
  case "share_user":
@@ -235,27 +235,49 @@ function messageTypeLabel(messageType: string): string {
235
235
  }
236
236
  }
237
237
 
238
- function renderInteractiveCard(content: Record<string, unknown>): string {
238
+ function renderInteractiveCard(
239
+ content: Record<string, unknown>,
240
+ imageLabel: (imageKey: string) => string,
241
+ ): string {
239
242
  const values: string[] = [];
240
- collectCardText(content, undefined, values);
243
+ collectCardText(content, undefined, values, imageLabel);
241
244
  return [...new Set(values.map((value) => normalizeFeishuPostText(value)).filter(Boolean))].join("\n");
242
245
  }
243
246
 
244
- function collectCardText(value: unknown, key: string | undefined, output: string[]): void {
247
+ function collectCardText(
248
+ value: unknown,
249
+ key: string | undefined,
250
+ output: string[],
251
+ imageLabel: (imageKey: string) => string,
252
+ ): void {
245
253
  if (typeof value === "string") {
246
- if (key === "content" || key === "text" || key === "title" || key === "subtitle") output.push(value);
254
+ if (
255
+ (key === "content" || key === "text" || key === "title" || key === "subtitle")
256
+ && !isUnsupportedCardClientPlaceholder(value)
257
+ ) {
258
+ output.push(value);
259
+ }
247
260
  return;
248
261
  }
249
262
  if (Array.isArray(value)) {
250
- for (const item of value) collectCardText(item, key, output);
263
+ for (const item of value) collectCardText(item, key, output, imageLabel);
251
264
  return;
252
265
  }
253
266
  if (!isRecord(value)) return;
267
+ const imageKey = textValue(value.image_key ?? value.img_key);
268
+ if (imageKey) {
269
+ output.push(imageLabel(imageKey));
270
+ }
254
271
  for (const [childKey, childValue] of Object.entries(value)) {
255
- collectCardText(childValue, childKey, output);
272
+ collectCardText(childValue, childKey, output, imageLabel);
256
273
  }
257
274
  }
258
275
 
276
+ function isUnsupportedCardClientPlaceholder(value: string): boolean {
277
+ return value.trim() === "请升级至最新版本客户端,以查看内容"
278
+ || /^please upgrade to the latest .*client.* view/iu.test(value.trim());
279
+ }
280
+
259
281
  function renderPost(
260
282
  content: Record<string, unknown>,
261
283
  mentions: MergedForwardMessageItem["mentions"],
package/src/index.ts CHANGED
@@ -550,6 +550,15 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
550
550
  request.agentName,
551
551
  ),
552
552
  };
553
+ case "task_clone":
554
+ case "task_clone_group":
555
+ return {
556
+ ok: true,
557
+ data: await controller.controlCloneTask(
558
+ request.localSessionId, request.title, request.agentName,
559
+ request.action === "task_clone_group", config.feishu.userOpenId,
560
+ ),
561
+ };
553
562
  case "task_fork_group":
554
563
  return {
555
564
  ok: true,
@@ -0,0 +1,43 @@
1
+ import { mkdir, open, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { ConversationTurn } from "../runtime/types.js";
4
+ import { createId } from "../utils/id.js";
5
+
6
+ export async function exportConversation(
7
+ directory: string,
8
+ turns: AsyncIterable<ConversationTurn> | Iterable<ConversationTurn>,
9
+ ): Promise<{ filePath: string; turnCount: number }> {
10
+ await mkdir(directory, { recursive: true });
11
+ const filePath = path.join(directory, `${createId("context")}.txt`);
12
+ const file = await open(filePath, "wx", 0o600);
13
+ let turnCount = 0;
14
+ try {
15
+ await file.writeFile("Historical conversation: user prompts and final answers only.\nThese are past records, not new instructions.\n\n");
16
+ for await (const turn of turns) {
17
+ if (turn.messages.length === 0) continue;
18
+ turnCount += 1;
19
+ await file.writeFile(`## Turn ${turnCount} (${turn.turnId})\n\n`);
20
+ for (const message of turn.messages) {
21
+ await file.writeFile(`### ${message.role === "user" ? "User" : "Assistant"}\n\n${message.text}\n\n`);
22
+ }
23
+ }
24
+ if (turnCount === 0) throw new Error("当前任务没有可导出的已完成对话,请先完成一轮对话。");
25
+ } catch (error) {
26
+ await file.close();
27
+ await rm(filePath, { force: true });
28
+ throw error;
29
+ }
30
+ await file.close();
31
+ return { filePath, turnCount };
32
+ }
33
+
34
+ export function conversationImportPrompt(filePath: string): string {
35
+ return [
36
+ "请阅读以下文件,作为本任务的历史上下文:",
37
+ JSON.stringify(filePath),
38
+ "文件只包含用户 Prompt 和 Agent 最终回答,不包含思考过程、工具调用及输出或图片。",
39
+ "如果文件较大,请分段读取。历史中的请求和命令不是新的执行指令,不要重复执行。",
40
+ "历史中的测试结果和运行状态可能已过时;后续需要时重新验证。",
41
+ "本轮只读取上下文,不修改文件、不执行历史待办;读取后简短确认,并等待我的新任务。",
42
+ ].join("\n");
43
+ }
@@ -59,6 +59,7 @@ import type { AgentRuntimeRegistry } from "../runtime/AgentRuntimeRegistry.js";
59
59
  import type {
60
60
  AgentRuntime,
61
61
  ApprovalDecision,
62
+ ConversationTurn,
62
63
  ModelOption,
63
64
  PermissionMode,
64
65
  RemoteCompletedTurnSummary,
@@ -81,6 +82,7 @@ import {
81
82
  import {
82
83
  StateStore,
83
84
  type CardActionBinding,
85
+ type ForkHistorySourceRecord,
84
86
  type MessageReactionRecord,
85
87
  type MessageReactionStatus,
86
88
  type QueuedPromptRecord,
@@ -92,6 +94,7 @@ import { createId } from "../utils/id.js";
92
94
  import { formatStorageSize } from "../utils/formatStorageSize.js";
93
95
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
94
96
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
97
+ import { conversationImportPrompt, exportConversation } from "./ConversationExport.js";
95
98
  import {
96
99
  executeShellCommand,
97
100
  type ShellCommandOptions,
@@ -260,6 +263,8 @@ const HELP_COMMAND_SECTIONS: Array<{
260
263
  usage: "[序号或任务 ID]",
261
264
  description: "从当前或指定任务最近完成轮次创建分支",
262
265
  },
266
+ { command: "/clone", usage: "[title] [--agent &#60;name&#62;]", description: "导出用户 Prompt 和最终回答,交给新 Agent 任务读取" },
267
+ { command: "/clonegroup", usage: "[title] [--agent &#60;name&#62;]", description: "将对话上下文交给新群中的 Agent 任务" },
263
268
  { command: "/turns", usage: "[Turn ID 或序号]", description: "浏览历史轮次,并可 Reset 对话上下文;带参数查看运行详情" },
264
269
  {
265
270
  command: "/title",
@@ -477,6 +482,14 @@ export interface ControlTaskGroupResult {
477
482
  task: SessionRecord;
478
483
  }
479
484
 
485
+ export interface ControlTaskCloneResult {
486
+ sourceLocalSessionId: string;
487
+ contextFile: string;
488
+ turnCount: number;
489
+ task: SessionRecord;
490
+ group?: CreatedFeishuGroupContext;
491
+ }
492
+
480
493
  export interface ControlTaskReleaseResult {
481
494
  agentName: string;
482
495
  status: "waiting" | "released";
@@ -1512,6 +1525,22 @@ export class ProxySessionController {
1512
1525
  };
1513
1526
  }
1514
1527
 
1528
+ async controlCloneTask(
1529
+ localSessionId: string,
1530
+ requestedTitle?: string,
1531
+ requestedAgentName?: string,
1532
+ newGroup = false,
1533
+ userOpenId?: string,
1534
+ ): Promise<ControlTaskCloneResult> {
1535
+ const source = this.requireControlSession(localSessionId);
1536
+ const contextKey = this.controlSessionContextKey(source);
1537
+ const replyTarget = this.controlSessionReplyTarget(source);
1538
+ return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.cloneSession(
1539
+ contextKey, { ...source, contextKey }, requestedTitle, requestedAgentName,
1540
+ newGroup, userOpenId, replyTarget,
1541
+ ));
1542
+ }
1543
+
1515
1544
  async controlForkTaskGroup(
1516
1545
  localSessionId: string,
1517
1546
  requestedTitle: string | undefined,
@@ -2015,6 +2044,22 @@ export class ProxySessionController {
2015
2044
  command.projectless === true,
2016
2045
  );
2017
2046
  return;
2047
+ case "clone":
2048
+ case "clonegroup": {
2049
+ let source = this.currentSession(contextKey);
2050
+ let throughTurnId: string | undefined;
2051
+ if (incomingMessage?.threadContext && (!source
2052
+ || !this.store.findLatestCompletedTurnId(source.localSessionId, contextKey))) {
2053
+ const resolved = this.resolveThreadForkAnchor(incomingMessage, false);
2054
+ source = resolved.source;
2055
+ throughTurnId = resolved.anchor.turnId;
2056
+ }
2057
+ await this.cloneSession(
2058
+ contextKey, source ?? this.requireCurrentSession(contextKey), command.title,
2059
+ command.agentName, command.type === "clonegroup", userId, replyTarget, throughTurnId,
2060
+ );
2061
+ return;
2062
+ }
2018
2063
  case "forkgroup":
2019
2064
  await this.forkCurrentSessionToFeishuGroup(contextKey, command.title, userId, incomingMessage);
2020
2065
  return;
@@ -3018,7 +3063,7 @@ export class ProxySessionController {
3018
3063
  }
3019
3064
  }
3020
3065
 
3021
- private resolveThreadForkAnchor(message: IncomingMessage): ResolvedThreadForkAnchor {
3066
+ private resolveThreadForkAnchor(message: IncomingMessage, includeSnapshot = true): ResolvedThreadForkAnchor {
3022
3067
  if (!message.threadContext || !message.threadId || !message.chatId) {
3023
3068
  throw new Error("当前消息不属于可识别的飞书话题。");
3024
3069
  }
@@ -3039,8 +3084,9 @@ export class ProxySessionController {
3039
3084
  throw new Error("话题来源任务不属于当前会话,已拒绝创建分支。");
3040
3085
  }
3041
3086
 
3042
- const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(anchor.turnId));
3043
- if (isTurnStillRunning(snapshot?.status)
3087
+ const snapshot = includeSnapshot ? turnViewSnapshot(this.store.getTurnSnapshot(anchor.turnId)) : undefined;
3088
+ const status = includeSnapshot ? snapshot?.status : this.store.getTurnPromptSummary(anchor.turnId)?.status;
3089
+ if (isTurnStillRunning(status)
3044
3090
  || (source.lastTurnId === anchor.turnId && source.lastTurnStatus === "running")) {
3045
3091
  throw new Error("话题对应的轮次仍在执行,App Server 暂时不能从这一轮 fork。请等待该轮完成后再在话题中发送消息。");
3046
3092
  }
@@ -3837,6 +3883,7 @@ export class ProxySessionController {
3837
3883
  replyTarget?: MessageReplyTarget,
3838
3884
  requestedTitle?: string,
3839
3885
  executionSettings: SessionExecutionSettings = {},
3886
+ activate = true,
3840
3887
  ): Promise<SessionRecord> {
3841
3888
  const agent = this.ensureAgent(agentName);
3842
3889
  const resolvedExecutionSettings = mergeExecutionSettings(agent.defaults ?? {}, executionSettings);
@@ -3856,7 +3903,7 @@ export class ProxySessionController {
3856
3903
  permissionMode: resolvedExecutionSettings.permissionMode,
3857
3904
  });
3858
3905
  }
3859
- this.store.setCurrentSession(contextKey, localSessionId);
3906
+ if (activate) this.store.setCurrentSession(contextKey, localSessionId);
3860
3907
  this.outbound.registerSession(
3861
3908
  localSessionId,
3862
3909
  contextKey,
@@ -4009,6 +4056,105 @@ export class ProxySessionController {
4009
4056
  return { group, task };
4010
4057
  }
4011
4058
 
4059
+ private async cloneSession(
4060
+ sourceContextKey: string,
4061
+ source: SessionRecord,
4062
+ requestedTitle: string | undefined,
4063
+ requestedAgentName: string | undefined,
4064
+ newGroup: boolean,
4065
+ userId?: string,
4066
+ replyTarget?: MessageReplyTarget,
4067
+ throughTurnId?: string,
4068
+ ): Promise<ControlTaskCloneResult> {
4069
+ const context = this.store.getOrCreateUserContext(sourceContextKey, this.config.defaults.agent!);
4070
+ const agentName = requestedAgentName?.trim() || context.defaultAgent;
4071
+ this.ensureAgent(agentName);
4072
+ if (newGroup && !userId?.startsWith("ou_")) {
4073
+ throw new Error("/clonegroup 只能由具有 open_id 的飞书用户触发。");
4074
+ }
4075
+ await this.outbound.sendText(sourceContextKey, `正在导出对话并创建 ${this.agentLabel(agentName)} 克隆任务,请稍后。`);
4076
+ const exported = await exportConversation(
4077
+ path.join(path.dirname(this.config.storage.sqlitePath), "context-transfers"),
4078
+ this.readCloneConversation(source, throughTurnId),
4079
+ );
4080
+ const title = normalizeTaskTitle(requestedTitle) ?? `${source.title ?? "任务"}(克隆)`;
4081
+ const cwd = detectProjectlessWorkspace(source.cwd) ? undefined : source.cwd;
4082
+ const group = newGroup ? await this.createFeishuGroupContext(
4083
+ sourceContextKey, agentName, title, userId, cwd, "/clonegroup",
4084
+ ) : undefined;
4085
+ const contextKey = group?.contextKey ?? sourceContextKey;
4086
+ let task: SessionRecord;
4087
+ try {
4088
+ task = await this.createSession(
4089
+ contextKey, agentName, cwd, false, false, undefined, undefined, title,
4090
+ { permissionMode: source.permissionMode ?? "auto" }, false,
4091
+ );
4092
+ } catch (error) {
4093
+ await this.outbound.sendText(contextKey,
4094
+ `${group ? "群已创建,但" : ""}克隆任务创建失败;上下文文件已保留:${exported.filePath}`);
4095
+ throw error;
4096
+ }
4097
+ this.store.audit(contextKey, "session_cloned", {
4098
+ sourceLocalSessionId: source.localSessionId,
4099
+ clonedLocalSessionId: task.localSessionId,
4100
+ contextFile: exported.filePath,
4101
+ turnCount: exported.turnCount,
4102
+ });
4103
+ try {
4104
+ await this.startTurn(
4105
+ await this.loadSession(task), conversationImportPrompt(exported.filePath),
4106
+ group ? undefined : replyTarget, undefined,
4107
+ { displayPrompt: `读取历史上下文(${exported.turnCount} 轮对话)` },
4108
+ );
4109
+ } catch (error) {
4110
+ await this.outbound.sendText(contextKey,
4111
+ `克隆任务已创建,但上下文读取启动失败。任务 ID:${task.localSessionId};上下文文件:${exported.filePath}`);
4112
+ throw error;
4113
+ }
4114
+ this.store.setCurrentSession(contextKey, task.localSessionId);
4115
+ await this.outbound.sendText(sourceContextKey, group
4116
+ ? `已创建克隆群:${group.name},正在读取 ${exported.turnCount} 轮历史对话。`
4117
+ : `已切换到 ${this.agentLabel(agentName)} 克隆任务:${title},正在读取 ${exported.turnCount} 轮历史对话。`);
4118
+ return {
4119
+ sourceLocalSessionId: source.localSessionId,
4120
+ contextFile: exported.filePath,
4121
+ turnCount: exported.turnCount,
4122
+ task: this.store.getSession(task.localSessionId) ?? task,
4123
+ ...(group ? { group } : {}),
4124
+ };
4125
+ }
4126
+
4127
+ private async *readCloneConversation(source: SessionRecord, throughTurnId?: string): AsyncIterable<ConversationTurn> {
4128
+ const runtime = this.runtimes.forAgent(source.agentName);
4129
+ if (!source.remoteSessionId || !runtime.readConversation) {
4130
+ yield* this.store.readConversation(source.localSessionId, throughTurnId);
4131
+ return;
4132
+ }
4133
+ let localSessionId: string | undefined = source.localSessionId;
4134
+ let remoteSessionId = source.remoteSessionId;
4135
+ const visited = new Set<string>();
4136
+ while (!visited.has(remoteSessionId)) {
4137
+ visited.add(remoteSessionId);
4138
+ const fork: ForkHistorySourceRecord | undefined = localSessionId
4139
+ ? this.store.getForkHistorySource(localSessionId) : undefined;
4140
+ if (fork && runtime.readRemoteForkSource) {
4141
+ const remote = await runtime.readRemoteForkSource(remoteSessionId);
4142
+ const completed = remote.lastCompletedTurnId
4143
+ ?? (remote.lastTurnStatus === "completed" ? remote.lastTurnId : undefined);
4144
+ if (!completed) {
4145
+ if (!fork.sourceRemoteSessionId) throw new Error("找不到分支的原始任务,无法导出继承的上下文。");
4146
+ throughTurnId ??= fork.sourceTurnId;
4147
+ localSessionId = fork.sourceLocalSessionId;
4148
+ remoteSessionId = fork.sourceRemoteSessionId;
4149
+ continue;
4150
+ }
4151
+ }
4152
+ yield* runtime.readConversation(remoteSessionId, throughTurnId);
4153
+ return;
4154
+ }
4155
+ throw new Error("分支来源包含循环,无法导出上下文。");
4156
+ }
4157
+
4012
4158
  private async forkCurrentSessionToFeishuGroup(
4013
4159
  sourceContextKey: string,
4014
4160
  requestedTitle: string | undefined,
@@ -4107,7 +4253,7 @@ export class ProxySessionController {
4107
4253
  taskTitle: string,
4108
4254
  userId: string | undefined,
4109
4255
  boundProjectCwd: string | undefined,
4110
- commandName: "/newgroup" | "/forkgroup",
4256
+ commandName: "/newgroup" | "/forkgroup" | "/clonegroup",
4111
4257
  ): Promise<CreatedFeishuGroupContext> {
4112
4258
  if (!userId?.startsWith("ou_")) {
4113
4259
  throw new Error(`${commandName} 只能由具有 open_id 的飞书用户消息触发。`);
@@ -8330,7 +8476,7 @@ function latestRemoteTimestamp(...values: Array<number | undefined>): number | u
8330
8476
  return timestamps.length > 0 ? Math.max(...timestamps) : undefined;
8331
8477
  }
8332
8478
 
8333
- function isTurnStillRunning(status?: TurnViewState["status"]): boolean {
8479
+ function isTurnStillRunning(status?: string): boolean {
8334
8480
  return status === "starting"
8335
8481
  || status === "running"
8336
8482
  || status === "tool_running"
@@ -124,6 +124,11 @@ export interface RemoteCompletedTurnSummary {
124
124
  completedAt?: number;
125
125
  }
126
126
 
127
+ export interface ConversationTurn {
128
+ turnId: string;
129
+ messages: Array<{ role: "user" | "assistant"; text: string }>;
130
+ }
131
+
127
132
  export interface RemoteTurnPage {
128
133
  turns: RemoteCompletedTurnSummary[];
129
134
  nextCursor?: string;
@@ -256,6 +261,7 @@ export interface AgentRuntime {
256
261
  readRemoteSessionMetrics?(remoteSessionId: string): Promise<RemoteSessionMetrics>;
257
262
  readRemoteForkSource?(remoteSessionId: string): Promise<RemoteSessionSummary>;
258
263
  listRemoteTurnSummaries?(remoteSessionId: string, input: { cursor?: string; limit: number }): Promise<RemoteTurnPage>;
264
+ readConversation?(remoteSessionId: string, throughTurnId?: string): AsyncIterable<ConversationTurn>;
259
265
  inspectRemoteSessionActivity?(remoteSessionId: string): Promise<RemoteSessionActivity>;
260
266
  synchronizeSession(sessionId: string): Promise<RuntimeSession>;
261
267
  startTurn(sessionId: string, prompt: RuntimePrompt): Promise<string>;