@keyou007/agent-bot 0.1.24-alpha.7 → 0.1.24

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 (39) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +9 -5
  3. package/README.zh.md +9 -5
  4. package/dist/codex/CodexProcessManager.js +3 -1
  5. package/dist/codex/CodexProcessManager.js.map +1 -1
  6. package/dist/codex/CodexRuntime.js +165 -23
  7. package/dist/codex/CodexRuntime.js.map +1 -1
  8. package/dist/codex/CodexVersion.js +2 -2
  9. package/dist/codex/CodexVersion.js.map +1 -1
  10. package/dist/codex/ProviderSwitch.js +2 -2
  11. package/dist/codex/ProviderSwitch.js.map +1 -1
  12. package/dist/feishu/CardRenderer.js +10 -6
  13. package/dist/feishu/CardRenderer.js.map +1 -1
  14. package/dist/feishu/FeishuConnector.js +7 -3
  15. package/dist/feishu/FeishuConnector.js.map +1 -1
  16. package/dist/local-files/TurnPreviewPage.js +26 -10
  17. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  18. package/dist/presentation/visibleTurnActivities.js +13 -0
  19. package/dist/presentation/visibleTurnActivities.js.map +1 -0
  20. package/dist/proxy/ProxySessionController.js +84 -17
  21. package/dist/proxy/ProxySessionController.js.map +1 -1
  22. package/dist/runtime/responseErrors.js +60 -0
  23. package/dist/runtime/responseErrors.js.map +1 -0
  24. package/docs/technical-reference.md +11 -1
  25. package/docs/technical-reference.zh.md +11 -1
  26. package/npm-shrinkwrap.json +2 -2
  27. package/package.json +1 -1
  28. package/skills/agent-bot/SKILL.md +5 -3
  29. package/src/codex/CodexProcessManager.ts +3 -1
  30. package/src/codex/CodexRuntime.ts +158 -22
  31. package/src/codex/CodexVersion.ts +2 -2
  32. package/src/codex/ProviderSwitch.ts +2 -2
  33. package/src/feishu/CardRenderer.ts +10 -5
  34. package/src/feishu/FeishuConnector.ts +7 -3
  35. package/src/local-files/TurnPreviewPage.ts +25 -10
  36. package/src/presentation/visibleTurnActivities.ts +12 -0
  37. package/src/proxy/ProxySessionController.ts +90 -20
  38. package/src/runtime/responseErrors.ts +62 -0
  39. package/src/runtime/types.ts +7 -1
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type { Logger } from "pino";
2
3
  import type { WSClient as LarkWsClient, WSConnectionStatus } from "@larksuiteoapi/node-sdk";
3
4
  import type { AppConfig } from "../config/schema.js";
@@ -395,7 +396,7 @@ function toCardAction(data: unknown) {
395
396
  const openId = event.operator?.open_id ?? event.operator?.user_id;
396
397
  const chatId = event.context?.open_chat_id ?? event.chat_id;
397
398
  return {
398
- actionId: getEventId(data) ?? event.action.action_id ?? event.action.name ?? `${Date.now()}`,
399
+ actionId: getEventId(data) ?? event.action.action_id ?? randomUUID(),
399
400
  contextKey: chatId ? `chat_id:${chatId}` : `open_id:${openId}`,
400
401
  userId: openId,
401
402
  ...(typeof event.context?.open_message_id === "string" ? { messageId: event.context.open_message_id } : {}),
@@ -415,8 +416,11 @@ function getCardActionValue(action: Record<string, unknown>): Record<string, unk
415
416
  }
416
417
 
417
418
  function getEventId(data: unknown): string | undefined {
418
- const value = getNested<unknown>(data, ["header", "event_id"]) ?? getNested(data, ["data", "header", "event_id"]);
419
- return typeof value === "string" ? value : undefined;
419
+ // The SDK flattens header fields into the event passed to handlers.
420
+ const value = getNested<unknown>(data, ["header", "event_id"])
421
+ ?? getNested(data, ["data", "header", "event_id"])
422
+ ?? getNested(data, ["event_id"]);
423
+ return typeof value === "string" && value ? value : undefined;
420
424
  }
421
425
 
422
426
  function getFeishuEvent(data: unknown): Record<string, any> | undefined {
@@ -8,6 +8,8 @@ import type { ApprovalRequest, ToolState } from "../runtime/types.js";
8
8
  import type { FileSummary, TurnActivity, TurnReasoningItem, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
9
9
  import { displayFilePath, displayToolCommand, formatShellCommandForDisplay, toolStatusIcon } from "../feishu/CardRenderer.js";
10
10
  import { turnReasoningItems } from "../presentation/turnReasoning.js";
11
+ import { visibleTurnActivities } from "../presentation/visibleTurnActivities.js";
12
+ import { displayRuntimeError } from "../runtime/responseErrors.js";
11
13
  import { isFileUrl, rewriteMarkdownFileLinks } from "./MarkdownFileLinks.js";
12
14
 
13
15
  const MARKDOWN = new MarkdownIt({
@@ -285,6 +287,10 @@ export const TURN_PREVIEW_CLIENT_SCRIPT = DIAGRAM_PREVIEW_CLIENT_SCRIPT + `(() =
285
287
  };
286
288
  const compatible = (current, next) => current.nodeType === next.nodeType && current.nodeName === next.nodeName;
287
289
  const patchNode = (current, next) => {
290
+ if (!compatible(current, next)) {
291
+ current.replaceWith(next.cloneNode(true));
292
+ return;
293
+ }
288
294
  // Summary snapshots never own a detail body that has already been fetched.
289
295
  if (current.nodeType === 1 && current.hasAttribute("data-detail-body") && next.hasAttribute("data-detail-body")) return;
290
296
  if (current.isEqualNode(next)) return;
@@ -620,16 +626,19 @@ export function renderTurnPreviewPatch(
620
626
  state: TurnViewState, changed: Set<string>, removed: Set<string>,
621
627
  localFileUrl: (filePath: string) => string | undefined, language: TurnPreviewLanguage,
622
628
  ): TurnPreviewSnapshot & { removed: string[]; before: Record<string, string> } {
629
+ const visible = visibleTurnActivities(state);
630
+ const visibleIds = new Set(visible.map((activity) => activity.id));
631
+ const suppressed = state.activities.filter((activity) => !visibleIds.has(activity.id)).map((activity) => activity.id);
623
632
  const partial = { ...state,
624
- activities: state.activities.filter((a) => changed.has(a.id)),
633
+ activities: visible.filter((a) => changed.has(a.id)),
625
634
  reasoningItems: state.reasoningItems?.filter((r) => changed.has(`reasoning:${r.itemId}`)),
626
635
  };
627
636
  const order = [
628
637
  ...(state.reasoningItems ?? []).filter((r) => !r.afterActivityId).map((r) => `reasoning:${r.itemId}`),
629
- ...state.activities.flatMap((a) => [a.id, ...(state.reasoningItems ?? []).filter((r) => r.afterActivityId === a.id).map((r) => `reasoning:${r.itemId}`)]),
638
+ ...visible.flatMap((a) => [a.id, ...(state.reasoningItems ?? []).filter((r) => r.afterActivityId === a.id).map((r) => `reasoning:${r.itemId}`)]),
630
639
  ];
631
640
  const before = Object.fromEntries(order.flatMap((id, i) => changed.has(id) && order[i + 1] ? [[id, order[i + 1]]] : []));
632
- return { ...renderTurnPreviewSnapshot(partial, localFileUrl, language, { deferDetails: true }), removed: [...removed], before };
641
+ return { ...renderTurnPreviewSnapshot(partial, localFileUrl, language, { deferDetails: true }), removed: [...new Set([...removed, ...suppressed])], before };
633
642
  }
634
643
 
635
644
  export function renderTurnPreviewSnapshot(
@@ -645,7 +654,7 @@ export function renderTurnPreviewSnapshot(
645
654
  return localFileUrl(path.resolve(state.projectCwd, filePath));
646
655
  } : undefined;
647
656
  const renderText = (value: string): string => renderMarkdown(value, state.projectCwd, localFileUrl, language);
648
- const timeline = renderTimeline(state, renderText, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd, options.deferDetails);
657
+ const timeline = renderTimeline({ ...state, activities: visibleTurnActivities(state) }, renderText, resolveFileUrl, state.fullToolOutputs, state.fullToolErrors, language, state.projectCwd, options.deferDetails);
649
658
  const terminal = isTerminal(state.status);
650
659
  const waitingForProgress = !terminal && !state.finalResponse && !state.assistantText
651
660
  && !state.error && !state.approval && state.status !== "waiting_for_approval"
@@ -666,7 +675,7 @@ export function renderTurnPreviewSnapshot(
666
675
  `<section class="timeline" data-preview-key="timeline"${timelineContent ? "" : " hidden"}>${timelineContent}</section>`,
667
676
  state.fileSummary.length > 0 ? renderFileSummary(state, language, resolveFileUrl, options.deferDetails) : "",
668
677
  state.approval ? renderApprovalNotice(state.approval, renderText, language) : "",
669
- state.error ? `<section class="result error-result" data-preview-key="error"><h2>${escapeHtml(labels.error)}</h2><pre>${escapeHtml(state.error)}</pre></section>` : "",
678
+ state.error ? `<section class="result error-result" data-preview-key="error"><h2>${escapeHtml(labels.error)}</h2><pre>${escapeHtml(displayRuntimeError(state.error))}</pre></section>` : "",
670
679
  state.finalResponse
671
680
  ? renderFinalAnswer(state.finalResponse, state.turnId, renderText, language)
672
681
  : state.assistantText
@@ -792,8 +801,12 @@ function renderReasoningItem(
792
801
  deferDetails: boolean,
793
802
  ): string {
794
803
  const presentation = reasoningPresentation(item);
795
- if (!presentation.title && !presentation.sections.some((section) => section.parts.length > 0)) return "";
804
+ const hasBody = presentation.sections.some((section) => section.parts.length > 0);
805
+ if (!presentation.title && !hasBody) return "";
796
806
  const title = presentation.title ?? previewLabels(language).reasoning;
807
+ if (!hasBody) {
808
+ return `<article class="reasoning-step" data-activity-id="reasoning:${escapeAttribute(item.itemId)}"><div class="reasoning-header" title="${escapeAttribute(title)}"><span class="reasoning-title">💭 ${escapeHtml(title)}</span></div></article>`;
809
+ }
797
810
  const lazy = deferDetails ? lazyDetailAttributes(`reasoning:${item.itemId}`, reasoningRevision(item, cwd)) : "";
798
811
  const body = deferDetails ? lazyDetailBody() : renderReasoningBody(item, cwd, localFileUrl, language, presentation);
799
812
  return `<details class="reasoning-step" data-activity-id="reasoning:${escapeAttribute(item.itemId)}"${lazy}><summary class="reasoning-header" title="${escapeAttribute(title)}"><span class="reasoning-title">💭 ${escapeHtml(title)}</span></summary>${body}</details>`;
@@ -831,7 +844,7 @@ function renderActivity(
831
844
  switch (activity.kind) {
832
845
  case "assistant":
833
846
  if (activity.id.startsWith("commentary:runtime-error:")) {
834
- return `<article class="activity message runtime-error" data-activity="${escapeAttribute(activity.id)}"><pre>${escapeHtml(activity.text)}</pre></article>`;
847
+ return `<article class="activity message runtime-error" data-activity="${escapeAttribute(activity.id)}"><pre>${escapeHtml(displayRuntimeError(activity.text))}</pre></article>`;
835
848
  }
836
849
  return renderMessageActivity(activity.id, activity.text, "commentary", renderText);
837
850
  case "user":
@@ -1163,9 +1176,11 @@ export function renderTurnPreviewDetail(
1163
1176
  }
1164
1177
  if (key.startsWith("reasoning:")) {
1165
1178
  const item = turnReasoningItems(state).find((item) => `reasoning:${item.itemId}` === key);
1166
- if (!item || ![...item.summary, ...item.content].some((text) => text?.trim())) return undefined;
1179
+ if (!item) return undefined;
1180
+ const presentation = reasoningPresentation(item);
1181
+ if (!presentation.sections.some((section) => section.parts.length > 0)) return undefined;
1167
1182
  return { key, revision: reasoningRevision(item, state.projectCwd),
1168
- content: renderReasoningBody(item, state.projectCwd, localFileUrl, language) };
1183
+ content: renderReasoningBody(item, state.projectCwd, localFileUrl, language, presentation) };
1169
1184
  }
1170
1185
  const activity = state.activities?.find((item) => item.kind === "tool" && `tool:${item.id}` === key);
1171
1186
  if (activity?.kind !== "tool") return undefined;
@@ -1334,7 +1349,7 @@ main { max-width: 1320px; margin: 0 auto; padding: 0 20px 40px; }
1334
1349
  .markdown > :first-child { margin-top: 0; }
1335
1350
  .markdown > :last-child { margin-bottom: 0; }
1336
1351
  .reasoning-step { margin:8px 0; border:1px solid var(--line); border-radius:7px; min-width:0; }
1337
- .reasoning-header { cursor:pointer; padding:8px 10px; color:var(--muted); }
1352
+ .reasoning-header { display:flex; align-items:center; gap:8px; min-height:32px; padding:8px 10px; color:var(--muted); }
1338
1353
  .reasoning-title { min-width:0; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1339
1354
  .reasoning-section { margin:0 10px 10px; max-height:30em; overflow:auto; overflow-wrap:anywhere; }
1340
1355
  .reasoning-part + .reasoning-part { border-top:1px solid var(--line); padding-top:8px; }
@@ -0,0 +1,12 @@
1
+ import { RUNTIME_WARNING_PREFIXES, sameRuntimeError } from "../runtime/responseErrors.js";
2
+ import type { TurnActivity, TurnViewState } from "./turnViewTypes.js";
3
+
4
+ export function visibleTurnActivities(state: TurnViewState): TurnActivity[] {
5
+ if (state.status !== "failed" || !state.error) return state.activities;
6
+ return state.activities.filter((activity) => {
7
+ if (activity.id !== `commentary:runtime-error:${state.turnId}`
8
+ || (activity.kind !== "assistant" && activity.kind !== "reasoning")) return true;
9
+ const prefix = RUNTIME_WARNING_PREFIXES.find((value) => activity.text.startsWith(value));
10
+ return !sameRuntimeError(prefix ? activity.text.slice(prefix.length) : activity.text, state.error!);
11
+ });
12
+ }
@@ -59,6 +59,7 @@ import type { TurnActivity, TurnViewState } from "../presentation/turnViewTypes.
59
59
  import { createTurnViewState, hydrateTurnViewState } from "../presentation/TurnStateReducer.js";
60
60
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
61
61
  import type { AgentRuntimeRegistry } from "../runtime/AgentRuntimeRegistry.js";
62
+ import { displayRuntimeError, isResponseContinuationError } from "../runtime/responseErrors.js";
62
63
  import type {
63
64
  AgentRuntime,
64
65
  ApprovalDecision,
@@ -76,6 +77,7 @@ import type {
76
77
  RuntimeExecutionSettings,
77
78
  RuntimePrompt,
78
79
  RuntimeSession,
80
+ RuntimeSettingsReplacement,
79
81
  } from "../runtime/types.js";
80
82
  import {
81
83
  isActiveShellCommandJob,
@@ -120,6 +122,7 @@ interface StartTurnOptions {
120
122
  displayPrompt?: string;
121
123
  preserveAttemptOnFailure?: boolean;
122
124
  approvedPlanTurnId?: string;
125
+ responseContinuationTurnId?: string;
123
126
  }
124
127
 
125
128
  interface PendingForwardAttachment {
@@ -566,6 +569,7 @@ export class ProxySessionController {
566
569
  private readonly lastSessionListings = new Map<string, string[]>();
567
570
  private readonly threadInitializations = new Map<string, Promise<void>>();
568
571
  private readonly llmRetryingSessions = new Set<string>();
572
+ private readonly responseContinuations = new Map<string, { turnId: string; abort: AbortController }>();
569
573
  private readonly planHandoffs = new Map<string, string>();
570
574
  private readonly retriedFailureTurnIds = new Set<string>();
571
575
  private readonly queuedPromptRetryTimers = new Map<string, NodeJS.Timeout>();
@@ -3976,6 +3980,16 @@ export class ProxySessionController {
3976
3980
  localImagePaths?: string[], options: StartTurnOptions = {},
3977
3981
  ): Promise<string> {
3978
3982
  return this.settingsOperations.run(loaded.record.localSessionId, async () => {
3983
+ if (options.responseContinuationTurnId) {
3984
+ const current = this.store.getSession(loaded.record.localSessionId);
3985
+ if (!current || current.status === "closed" || current.lastTurnId !== options.responseContinuationTurnId
3986
+ || this.sessionResets.has(current.localSessionId)
3987
+ || this.responseContinuations.get(current.localSessionId)?.turnId !== options.responseContinuationTurnId
3988
+ || this.store.getPendingProviderSettings(current.localSessionId)) {
3989
+ throw new Error("任务或运行设置已变化,未自动恢复模型续接。请发送消息继续。");
3990
+ }
3991
+ return this.startTurnNow(loaded, text, replyTarget, localImagePaths, options);
3992
+ }
3979
3993
  try {
3980
3994
  await this.applyPendingProviderSettings(loaded);
3981
3995
  } catch (error) {
@@ -4050,17 +4064,27 @@ export class ProxySessionController {
4050
4064
  || this.sessionResets.has(loaded.record.localSessionId))) {
4051
4065
  throw new Error("方案执行已取消。");
4052
4066
  }
4053
- turnId = await loaded.runtime.startTurn(
4054
- loaded.record.localSessionId,
4055
- options.approvedPlanTurnId
4056
- ? { text: runtimeText, collaborationMode: "default" }
4057
- : runtimePrompt(runtimeText, localImagePaths),
4058
- );
4067
+ if (options.responseContinuationTurnId) {
4068
+ const recovery = this.responseContinuations.get(loaded.record.localSessionId);
4069
+ if (recovery?.turnId !== options.responseContinuationTurnId
4070
+ || this.sessionResets.has(loaded.record.localSessionId) || !loaded.runtime.recoverResponseContinuation) {
4071
+ throw new Error("模型续接恢复已取消。");
4072
+ }
4073
+ turnId = await loaded.runtime.recoverResponseContinuation(loaded.record.localSessionId, options.responseContinuationTurnId, recovery.abort.signal);
4074
+ } else {
4075
+ turnId = await loaded.runtime.startTurn(
4076
+ loaded.record.localSessionId,
4077
+ options.approvedPlanTurnId
4078
+ ? { text: runtimeText, collaborationMode: "default" }
4079
+ : runtimePrompt(runtimeText, localImagePaths),
4080
+ );
4081
+ }
4059
4082
  break;
4060
4083
  } catch (error) {
4061
4084
  const message = error instanceof Error ? error.message : String(error);
4062
4085
  const attempt = this.store.getTurnAttempt(attemptId);
4063
4086
  const retryAttempt = attempt
4087
+ && !options.responseContinuationTurnId
4064
4088
  && isRetryableLlmTurnFailure(message)
4065
4089
  && attempt.retryCount < MAX_LLM_TURN_RETRIES
4066
4090
  ? this.store.prepareUnstartedTurnAttemptRetry(attemptId)
@@ -4794,7 +4818,23 @@ export class ProxySessionController {
4794
4818
  : undefined;
4795
4819
  if (event.type === "turn_failed") {
4796
4820
  if (this.retriedFailureTurnIds.has(event.turnId)) return;
4797
- if (failedAttempt && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
4821
+ const source = this.store.getSession(event.sessionId);
4822
+ const responseContinuation = failedAttempt?.retryCount === 0 && failedAttempt.status !== "cancelling"
4823
+ && source && source.status !== "closed" && source.lastTurnId === event.turnId
4824
+ && !this.sessionResets.has(event.sessionId) && !this.store.getPendingProviderSettings(event.sessionId)
4825
+ && Boolean(this.runtimes.forAgent(source.agentName).recoverResponseContinuation)
4826
+ && isResponseContinuationError(event.message);
4827
+ if (failedAttempt && responseContinuation) {
4828
+ const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
4829
+ if (retryAttempt) {
4830
+ this.retriedFailureTurnIds.add(event.turnId);
4831
+ this.responseContinuations.set(event.sessionId, { turnId: event.turnId, abort: new AbortController() });
4832
+ await this.retryFailedLlmTurn(event, retryAttempt, true);
4833
+ return;
4834
+ }
4835
+ }
4836
+ if (failedAttempt && !this.responseContinuations.has(event.sessionId)
4837
+ && isRetryableLlmTurnFailure(event.message) && failedAttempt.retryCount < MAX_LLM_TURN_RETRIES) {
4798
4838
  const retryAttempt = this.store.prepareTurnAttemptRetry(failedAttempt.attemptId, event.turnId);
4799
4839
  if (retryAttempt) {
4800
4840
  this.retriedFailureTurnIds.add(event.turnId);
@@ -4957,6 +4997,7 @@ export class ProxySessionController {
4957
4997
  private async retryFailedLlmTurn(
4958
4998
  event: Extract<RuntimeEvent, { type: "turn_failed" }>,
4959
4999
  attempt: TurnAttemptRecord,
5000
+ responseContinuation = false,
4960
5001
  ): Promise<void> {
4961
5002
  const retryNumber = attempt.retryCount;
4962
5003
  const session = this.store.getSession(event.sessionId);
@@ -4974,14 +5015,17 @@ export class ProxySessionController {
4974
5015
  attemptId: attempt.attemptId,
4975
5016
  failedTurnId: event.turnId,
4976
5017
  retryNumber,
4977
- maxRetries: MAX_LLM_TURN_RETRIES,
5018
+ maxRetries: responseContinuation ? 1 : MAX_LLM_TURN_RETRIES,
5019
+ responseContinuation,
4978
5020
  error: event.message,
4979
5021
  });
4980
5022
 
4981
5023
  try {
4982
5024
  await this.outbound.onEvent({
4983
5025
  ...event,
4984
- message: `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
5026
+ message: responseContinuation
5027
+ ? `${event.message}\n\n模型续接状态失效,正在恢复连接并继续(1/1)。`
5028
+ : `${event.message}\n\n检测到临时模型服务错误,正在自动重试(${retryNumber}/${MAX_LLM_TURN_RETRIES})。`,
4985
5029
  });
4986
5030
  } catch (error) {
4987
5031
  this.logger.warn(
@@ -4998,21 +5042,26 @@ export class ProxySessionController {
4998
5042
  const loaded = await this.loadSession(current, Boolean(this.store.getPendingProviderSettings(current.localSessionId)));
4999
5043
  const retryTurnId = await this.startTurn(
5000
5044
  loaded,
5001
- llmRetryRuntimePrompt(attempt.promptText, retryNumber),
5045
+ responseContinuation ? "" : llmRetryRuntimePrompt(attempt.promptText, retryNumber),
5002
5046
  replyTarget,
5003
- attempt.localImagePaths,
5047
+ responseContinuation ? undefined : attempt.localImagePaths,
5004
5048
  {
5005
5049
  attemptId: attempt.attemptId,
5006
5050
  messageId: attempt.messageId,
5007
- displayPrompt: llmRetryCardPrompt(attempt.promptText, retryNumber),
5051
+ displayPrompt: responseContinuation ? `恢复模型续接:${attempt.promptText}` : llmRetryCardPrompt(attempt.promptText, retryNumber),
5008
5052
  preserveAttemptOnFailure: true,
5053
+ ...(responseContinuation ? { responseContinuationTurnId: event.turnId } : {}),
5009
5054
  },
5010
5055
  );
5011
5056
  this.store.rebindPendingTurnMessages(session.localSessionId, event.turnId, retryTurnId);
5012
5057
  } catch (error) {
5013
- this.store.updateTurnAttempt(attempt.attemptId, { status: "failed" });
5014
- this.store.updateSession(session.localSessionId, { status: "failed" });
5015
- await this.finalizeTurnMessageReactions(event.turnId, "failed").catch((reactionError: unknown) => {
5058
+ const aborted = responseContinuation && !this.responseContinuations.has(session.localSessionId);
5059
+ this.store.updateTurnAttempt(attempt.attemptId, { status: aborted ? "cancelled" : "failed" });
5060
+ const latest = this.store.getSession(session.localSessionId);
5061
+ if (!responseContinuation || (latest?.lastTurnId === event.turnId && latest.status !== "closed")) {
5062
+ this.store.updateSession(session.localSessionId, { status: aborted ? "ready" : "failed" });
5063
+ }
5064
+ await this.finalizeTurnMessageReactions(event.turnId, aborted ? "cancelled" : "failed").catch((reactionError: unknown) => {
5016
5065
  this.logger.warn(
5017
5066
  { error: reactionError, sessionId: session.localSessionId, turnId: event.turnId },
5018
5067
  "Failed to finalize reactions after an LLM retry could not start.",
@@ -5022,9 +5071,16 @@ export class ProxySessionController {
5022
5071
  { error, sessionId: session.localSessionId, retryNumber },
5023
5072
  "Failed to start an automatic LLM turn retry.",
5024
5073
  );
5074
+ if (responseContinuation && !aborted) {
5075
+ await this.outbound.sendText(attempt.contextKey,
5076
+ `模型续接恢复未能启动:${displayRuntimeError(runtimeErrorMessage(error))}。请发送消息继续。`);
5077
+ }
5025
5078
  await this.scheduleNextQueuedPrompt(session.localSessionId);
5026
5079
  } finally {
5027
5080
  this.llmRetryingSessions.delete(session.localSessionId);
5081
+ if (this.responseContinuations.get(session.localSessionId)?.turnId === event.turnId) {
5082
+ this.responseContinuations.delete(session.localSessionId);
5083
+ }
5028
5084
  }
5029
5085
  }
5030
5086
 
@@ -5291,6 +5347,8 @@ export class ProxySessionController {
5291
5347
  }
5292
5348
 
5293
5349
  private async cancelSession(record: SessionRecord): Promise<void> {
5350
+ this.responseContinuations.get(record.localSessionId)?.abort.abort();
5351
+ this.responseContinuations.delete(record.localSessionId);
5294
5352
  this.planHandoffs.delete(record.localSessionId);
5295
5353
  const runtime = this.runtimes.forAgent(record.agentName);
5296
5354
  if (runtime.kind === "codex" && record.remoteSessionId && runtime.interruptRemoteTurn) {
@@ -5370,6 +5428,8 @@ export class ProxySessionController {
5370
5428
  }
5371
5429
 
5372
5430
  private async runSessionReset(sessionId: string, operation: () => Promise<void>): Promise<void> {
5431
+ this.responseContinuations.get(sessionId)?.abort.abort();
5432
+ this.responseContinuations.delete(sessionId);
5373
5433
  this.planHandoffs.delete(sessionId);
5374
5434
  const previous = this.sessionResets.get(sessionId) ?? Promise.resolve();
5375
5435
  const reset = previous.catch(() => undefined).then(operation);
@@ -5844,9 +5904,16 @@ export class ProxySessionController {
5844
5904
  if (!loaded.runtime.setExecutionSettings) throw new Error("当前运行时不支持 Provider 设置。");
5845
5905
  loaded.session = loaded.runtime.getSession(loaded.record.localSessionId) ?? loaded.session;
5846
5906
  const remoteSessionId = loaded.session.remoteSessionId;
5847
- const persist = (candidate: RuntimeSession): Promise<void> => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
5907
+ const persist = (candidate: RuntimeSession, replacement?: RuntimeSettingsReplacement): Promise<void> => this.agentDefaultsOperations.run(loaded.record.agentName, async () => {
5848
5908
  if (candidate.remoteSessionId !== remoteSessionId) {
5849
- throw new Error("设置切换返回了不同的任务 ID,未保存设置;不允许替换任务或更改历史。");
5909
+ const saved = this.store.getSession(loaded.record.localSessionId);
5910
+ if (replacement?.emptyRemoteSessionId !== remoteSessionId || !saved || saved.remoteSessionId !== remoteSessionId
5911
+ || saved.status === "closed" || saved.status === "running" || saved.lastTurnId || loaded.session.activeTurnId || candidate.activeTurnId
5912
+ || candidate.localSessionId !== saved.localSessionId || !candidate.remoteSessionId
5913
+ || candidate.agentName !== saved.agentName || candidate.cwd !== saved.cwd || candidate.runtimeKind !== loaded.session.runtimeKind
5914
+ || this.store.hasTurnAttemptsForSession(saved.localSessionId) || this.store.getForkHistorySource(saved.localSessionId)) {
5915
+ throw new Error("设置切换返回了不同的任务 ID,未保存设置;仅允许已验证的空会话更换底层任务。");
5916
+ }
5850
5917
  }
5851
5918
  assertProviderSettingsApplied(candidate, settings);
5852
5919
  const agent = this.ensureAgent(loaded.record.agentName);
@@ -5854,6 +5921,7 @@ export class ProxySessionController {
5854
5921
  await this.persistAgentExecutionDefaultsNow(loaded.record.localSessionId, candidate);
5855
5922
  try {
5856
5923
  this.store.updateRuntimeSession(loaded.record.localSessionId, {
5924
+ remoteSessionId: candidate.remoteSessionId,
5857
5925
  modelProvider: candidate.modelProvider,
5858
5926
  model: candidate.model,
5859
5927
  reasoningEffort: candidate.reasoningEffort,
@@ -5866,6 +5934,7 @@ export class ProxySessionController {
5866
5934
  }
5867
5935
  });
5868
5936
  const session = await loaded.runtime.setExecutionSettings(loaded.record.localSessionId, settings, persist);
5937
+ loaded.record = this.store.getSession(loaded.record.localSessionId) ?? loaded.record;
5869
5938
  this.settingsOnlySessions.delete(loaded.record.localSessionId);
5870
5939
  this.outbound.updateSessionModel(loaded.record.localSessionId, session.model, session.modelProvider);
5871
5940
  return session;
@@ -7107,6 +7176,7 @@ export class ProxySessionController {
7107
7176
  }
7108
7177
 
7109
7178
  private async createDirectoryFromCard(contextKey: string, action: CardAction): Promise<void> {
7179
+ const updateMessageId = requiredCardMessageId(action.messageId);
7110
7180
  const directory = directoryActionPath(action.value.directory);
7111
7181
  await this.assertBrowsableDirectory(directory);
7112
7182
  const folderName = directoryFolderName(cardFormValue(action.value, "folderName"));
@@ -7120,9 +7190,9 @@ export class ProxySessionController {
7120
7190
  if (code === "EEXIST") throw new Error(`目录已存在:${newDirectory}`);
7121
7191
  throw new Error(`无法创建目录 ${newDirectory}:${runtimeErrorMessage(error)}`);
7122
7192
  }
7123
- await this.openDirectoryBrowser(contextKey, directory, {
7124
- updateMessageId: requiredCardMessageId(action.messageId),
7125
- page: directoryPageValue(action.value.page),
7193
+ await this.openDirectoryBrowser(contextKey, newDirectory, {
7194
+ updateMessageId,
7195
+ page: 0,
7126
7196
  });
7127
7197
  await this.outbound.sendText(contextKey, `已创建目录:${newDirectory}`);
7128
7198
  }
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+
3
+ export const RUNTIME_WARNING_PREFIXES = ["运行请求失败,等待 Agent 返回最终状态:", "运行请求出错,Agent 正在重试:"];
4
+
5
+ const responseErrorSchema = z.object({
6
+ type: z.literal("error"),
7
+ status: z.number().optional(),
8
+ error: z.object({
9
+ message: z.string().min(1),
10
+ code: z.string().nullish(),
11
+ param: z.string().nullish(),
12
+ type: z.string().nullish(),
13
+ }).passthrough(),
14
+ }).passthrough();
15
+
16
+ function parseResponseError(message: string): { value: z.infer<typeof responseErrorSchema>; notice: string } | undefined {
17
+ // Agent Bot may append a recovery notice after the upstream JSON error.
18
+ const boundary = message.indexOf("\n\n");
19
+ for (const end of [message.length, ...(boundary >= 0 ? [boundary] : [])]) {
20
+ try {
21
+ const parsed = responseErrorSchema.safeParse(JSON.parse(message.slice(0, end)));
22
+ if (parsed.success) return { value: parsed.data, notice: message.slice(end) };
23
+ } catch {
24
+ // Non-JSON runtime errors remain unchanged.
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ export function isResponseContinuationError(message: string): boolean {
31
+ const parsed = parseResponseError(message);
32
+ return parsed?.value.error.code === "unsupported_persisted_item_context"
33
+ && parsed.value.error.param === "previous_response_id"
34
+ && parsed.value.error.type === "invalid_request_error"
35
+ && parsed.value.status === 400
36
+ && !parsed.notice;
37
+ }
38
+
39
+ export function displayRuntimeError(message: string): string {
40
+ const prefix = RUNTIME_WARNING_PREFIXES.find((value) => message.startsWith(value));
41
+ const parsed = parseResponseError(prefix ? message.slice(prefix.length) : message);
42
+ if (!parsed) return message;
43
+ const { error, status } = parsed.value;
44
+ const details = [status === undefined ? undefined : `HTTP ${status}`, error.code, error.param];
45
+ return (prefix ?? "") + `${error.message}\n${details.filter(Boolean).join(" · ")}${parsed.notice}`.trim();
46
+ }
47
+
48
+ export function sameRuntimeError(warning: string, terminal: string): boolean {
49
+ if (warning.trim() === terminal.trim()) return true;
50
+ const parsed = parseResponseError(terminal);
51
+ if (parsed) {
52
+ const other = parseResponseError(warning);
53
+ return Boolean(other && !other.notice && (!parsed.notice || isRecoveryNotice(parsed.notice))
54
+ && JSON.stringify(other.value) === JSON.stringify(parsed.value));
55
+ }
56
+ const suffix = terminal.slice(warning.trim().length);
57
+ return terminal.startsWith(warning.trim()) && isRecoveryNotice(suffix);
58
+ }
59
+
60
+ function isRecoveryNotice(text: string): boolean {
61
+ return /^\n\n(?:检测到临时模型服务错误,正在自动重试|模型续接状态失效,正在恢复连接并继续|已自动重试)/u.test(text);
62
+ }
@@ -270,6 +270,10 @@ export interface RuntimeExecutionSettings {
270
270
  permissionMode: PermissionMode;
271
271
  }
272
272
 
273
+ export interface RuntimeSettingsReplacement {
274
+ emptyRemoteSessionId: string;
275
+ }
276
+
273
277
  export interface RuntimeReleaseResult {
274
278
  status: "released" | "busy";
275
279
  activeSessionIds?: string[];
@@ -304,6 +308,8 @@ export interface AgentRuntime {
304
308
  inspectRemoteSessionActivity?(remoteSessionId: string): Promise<RemoteSessionActivity>;
305
309
  synchronizeSession(sessionId: string): Promise<RuntimeSession>;
306
310
  startTurn(sessionId: string, prompt: RuntimePrompt): Promise<string>;
311
+ // Continue a verified Responses replay failure without resubmitting user input.
312
+ recoverResponseContinuation?(sessionId: string, failedTurnId: string, signal?: AbortSignal): Promise<string>;
307
313
  steerTurn(sessionId: string, turnId: string, prompt: RuntimePrompt): Promise<void>;
308
314
  cancelTurn(sessionId: string, turnId: string): Promise<void>;
309
315
  interruptRemoteTurn?(remoteSessionId: string, turnId: string): Promise<void>;
@@ -320,7 +326,7 @@ export interface AgentRuntime {
320
326
  sessionId: string,
321
327
  settings: RuntimeExecutionSettings,
322
328
  // Called after remote verification, before releasing the task's operation lock.
323
- persist?: (session: RuntimeSession) => Promise<void>,
329
+ persist?: (session: RuntimeSession, replacement?: RuntimeSettingsReplacement) => Promise<void>,
324
330
  ): Promise<RuntimeSession>;
325
331
  respondToApproval(sessionId: string, requestId: string, decision: ApprovalDecision): Promise<void>;
326
332
  listModels(modelProvider?: string, fallbackModel?: string): Promise<ModelOption[]>;