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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +12 -6
  3. package/README.zh.md +12 -6
  4. package/dist/codex/CodexRuntime.js +141 -21
  5. package/dist/codex/CodexRuntime.js.map +1 -1
  6. package/dist/codex/ProviderSwitch.js +1 -1
  7. package/dist/codex/ProviderSwitch.js.map +1 -1
  8. package/dist/feishu/CardRenderer.js +10 -6
  9. package/dist/feishu/CardRenderer.js.map +1 -1
  10. package/dist/feishu/FeishuConnector.js +10 -3
  11. package/dist/feishu/FeishuConnector.js.map +1 -1
  12. package/dist/feishu/MergedForwardMessage.js +14 -11
  13. package/dist/feishu/MergedForwardMessage.js.map +1 -1
  14. package/dist/local-files/TurnPreviewPage.js +26 -10
  15. package/dist/local-files/TurnPreviewPage.js.map +1 -1
  16. package/dist/presentation/visibleTurnActivities.js +13 -0
  17. package/dist/presentation/visibleTurnActivities.js.map +1 -0
  18. package/dist/proxy/ProxySessionController.js +169 -92
  19. package/dist/proxy/ProxySessionController.js.map +1 -1
  20. package/dist/runtime/responseErrors.js +60 -0
  21. package/dist/runtime/responseErrors.js.map +1 -0
  22. package/docs/technical-reference.md +12 -2
  23. package/docs/technical-reference.zh.md +12 -2
  24. package/npm-shrinkwrap.json +2 -2
  25. package/package.json +1 -1
  26. package/skills/agent-bot/SKILL.md +5 -1
  27. package/src/codex/CodexRuntime.ts +139 -20
  28. package/src/codex/ProviderSwitch.ts +1 -1
  29. package/src/feishu/CardRenderer.ts +10 -5
  30. package/src/feishu/FeishuConnector.ts +10 -3
  31. package/src/feishu/MergedForwardMessage.ts +14 -11
  32. package/src/feishu/types.ts +3 -0
  33. package/src/local-files/TurnPreviewPage.ts +25 -10
  34. package/src/presentation/visibleTurnActivities.ts +12 -0
  35. package/src/proxy/ProxySessionController.ts +175 -105
  36. package/src/runtime/responseErrors.ts +62 -0
  37. package/src/runtime/types.ts +7 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,34 @@ The project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.25-alpha.0] - 2026-10-30
10
+
11
+ - 修复合并转发聊天记录中的图片、文件下载失败:改用外层消息 ID 读取资源,同时覆盖直接转发和引用聊天记录,保留用户正文为主 Prompt。
12
+ - Fix image and file downloads in merged-forward chat records by using the container message ID, including quoted records, while keeping the user's instruction as the main Prompt.
13
+
14
+ - 统一引用消息处理:不再按类型筛选待合并消息,普通文字和纯链接也可与同一用户、同一会话的显式引用附言合并;以当前正文作为主 Prompt 和卡片标题,保留引用资源、Reaction 和旧消息引用行为。
15
+ - Unify quoted-message pairing across message types, including plain text and URLs: use the explicitly linked current instruction as the Prompt/card title, preserving reference resources, reactions, and older-quote behavior.
16
+
17
+ - Add minimal delayed-Feishu-message diagnostics: retain source timestamps and event IDs in existing audits, with one content-free warning only for newly accepted messages delayed by at least one minute. Message handling remains unchanged.
18
+
19
+ ## [0.1.24] - 2026-10-24
20
+
21
+ ### 中文
22
+
23
+ - 修复 Turn Preview 只有思考标题、没有正文时仍显示空折叠箭头的问题;后续收到正文才启用展开,保留实时局部更新及飞书思考卡片行为。
24
+ - 修复飞书 SDK 展平事件 ID 后,新建目录按钮因固定名称被误去重而失效的问题;创建成功后原文件浏览卡片进入新目录,不改变任务工作目录。
25
+ - 允许无轮次、分支历史或 Goal 的空会话切换 Provider:验证目标设置后更换底层线程,保留本地任务和群绑定,失败保留原会话。
26
+ - Codex 遇到特定的 Responses 续接状态失效时,在原任务中自动尝试一次空输入续接,保留历史和已完成的工具结果,不重发 Prompt/图片;尊重取消、Reset、设置变化和任务接管。
27
+ - 修复思考卡片及 Preview 重复显示同一运行警告与最终错误,实时更新移除重复项;提取结构化错误的可读信息并保留原始诊断记录。
28
+
29
+ ### English
30
+
31
+ - Render title-only reasoning in Turn Preview without an empty disclosure or detail request; enable expansion when a body arrives, preserving live incremental updates and Feishu thinking cards.
32
+ - Fix folder creation being silently deduplicated by the button's fixed name after the Feishu SDK flattens event IDs; open the created directory in the same browser card without changing the task's working directory.
33
+ - Allow verified empty tasks without Turns, inherited history, or a Goal to switch Providers through a new remote thread, preserving local task/chat bindings and retaining the original on failure.
34
+ - Recover a specific Codex Responses continuation-state rejection once with empty input on the same task, preserving history and completed tool outputs without replaying the Prompt/images; honor cancellation, Reset, settings changes, and newer work.
35
+ - Deduplicate matching runtime warnings and final errors in thinking cards and Preview, including live updates; display readable structured error details while retaining original diagnostic records.
36
+
9
37
  ## [0.1.24-alpha.8] - 2026-10-19
10
38
 
11
39
  ### 中文
package/README.md CHANGED
@@ -282,6 +282,8 @@ Renaming a Feishu group updates its currently bound task title, even when you re
282
282
 
283
283
  Private chats, group timelines, and topics keep separate current tasks. A new topic remains unbound while you use commands such as `/help`, `/status`, or `/sessions`; those commands do not create a hidden fork. Its first ordinary message forks from the mapped source turn, or starts a fresh task when no source turn can be identified. `/new` starts a fresh topic task, while `/sessions` can bind an existing task. Commands that require a current task explain how to bind one instead of operating on the parent conversation. Ordinary messages sent while a task is running add instructions to that turn; use `/queue` when the message should run afterward as a separate turn.
284
284
 
285
+ For delayed-message diagnosis, existing incoming-message audits include the Feishu creation time, SDK callback time, and event ID when available. Only newly accepted messages delayed by at least one minute emit a warning, without message content; normal messages and duplicate deliveries add no log lines. These diagnostics do not reject or reorder messages, and SDK callback time does not identify when the WebSocket frame reached the host.
286
+
285
287
  `/release` sends a card for releasing the shared App Server used by the current task's Agent so its tasks can be opened in Codex Desktop. The card lists blocking task names and keeps them updated. Agent Bot never releases automatically: click **Release** when idle, or **Release Now** to interrupt active work and clear queued Prompts. **Cancel** cancels the pending release. Releasing does not archive or delete task history, but it affects every task loaded by that shared App Server.
286
288
 
287
289
  Topic replies check whether the root message is already in the task's history using lightweight Turn links, without loading the full history before sending the thinking card. Legacy parent links are repaired only when needed.
@@ -292,6 +294,8 @@ Only groups explicitly enabled with `/mute` or `/mute on` supplement ordinary @-
292
294
 
293
295
  `/new` and `/newgroup` use the conversation's default Agent unless `--agent <name>` selects another configured Agent for this task only; the source conversation's default is unchanged. For example, `/new Fix tests --agent codex` or `/newgroup Review --agent traex --dir ~/dev/project`. They inherit the current project; execution settings are inherited only when the selected Agent matches the current task's Agent, otherwise Provider, model, reasoning, and permission mode use the target Agent's saved defaults. Use `--dir` to choose another directory or `--nodir` to start without a project directory; `--nodir` requires the selected Agent to be App Server, and `~` represents your home directory.
294
296
 
297
+ `/dir` opens the file browser. `NewFolder` creates a child directory and opens it in the same card at page one, without changing the task's working directory. `Back` cancels creation and restores the previous directory and page.
298
+
295
299
  `/file` accepts relative paths, absolute paths, and paths beginning with `~`; relative paths resolve from the current task directory.
296
300
 
297
301
  `/fork` and `/forkgroup` branch from completed work without interrupting a running turn. `/sessions` manages tasks across projects and Providers in pages of up to 10 tasks; switching Provider does not hide a task from the list or search. Use each project menu for `New` and `NewGroup`. Each task’s `SwitchGroup` action creates a new group bound to that same task, preserving its history, title, and execution settings without creating or forking an Agent task. The original conversation stays unchanged. An Agent Bot Turn already in progress keeps replying to its original conversation; new messages from the new group queue until it finishes. Tasks running in an external client cannot be switched until they finish. Expanding a task directly shows the first 50 characters of its latest user Prompt, its update time, and task-specific actions. Running tasks also show a direct `Stop` button, including the current task and tasks running in other conversations or external clients. It sends the normal Agent interrupt without switching tasks or killing processes. The list refresh preserves search and pagination and keeps Stop available until the Agent reports the task idle; Archive is available only when idle. Use `Turns` to view that task’s history without switching tasks, including tasks not yet bound to Agent Bot; non-current tasks are read-only until you switch to them. `/turns` restores conversation context without reverting local files.
@@ -400,17 +404,19 @@ For App Server tasks, permission changes apply both approval and sandbox policie
400
404
 
401
405
  The execution settings card ends with a one-sentence Markdown blockquote for the selected tab's scope and timing. Agent selection changes only the default for new tasks in that conversation, without migrating the current task. Model, reasoning, and permission changes apply from the next turn. Agent and Provider options show their identifiers without duplicate display names, while retaining current/default indicators. `/provider` opens this card even when only one Provider is available, keeping the Model, Thinking, and Permission tabs accessible.
402
406
 
403
- Provider changes requested during an Agent Bot turn are saved as pending without interrupting it. The settings card immediately shows the target Provider and the model that will be used; the Model tab lists that Provider’s models, and later selections replace the pending choice. Thinking and permission changes made while a Provider switch is pending are staged with it. The switch is applied after the current turn ends, before a queued, retried, or new turn starts; idle tasks switch immediately. Pending settings survive restarts and do not overwrite active settings or Agent defaults until applied. If a deferred switch fails, Agent Bot restores the previous Provider and model. After verified rollback, the card returns to the original settings with an error notice, clears the pending choice, and queued/new turns continue using the original settings. Only failed or unverified rollback pauses further turns and keeps queued prompts for manual recovery. For a custom Provider, Agent Bot uses its OpenAI-compatible `/models` endpoint when available and shows only that Provider's models. A switch keeps the previous model when the target supports it, otherwise selects the target's default model or its first returned model. Providers without a model-list endpoint remain usable: Agent Bot keeps the current or configured model as the single fallback candidate and lets Codex apply it. Agent Bot verifies the task ID, Provider, and model before saving or reporting success. Settings changes always preserve the remote thread ID and all history, including failed and interrupted Turns. They never start, fork, truncate, or replace a thread. A pristine thread without a saved rollout is refused before detaching rather than silently replaced. If a switch fails, the previous settings are retained and remote recovery is attempted. If recovery also fails, further turns are blocked until a Provider switch succeeds.
407
+ Provider changes requested during an Agent Bot turn are saved as pending without interrupting it. The settings card immediately shows the target Provider and the model that will be used; the Model tab lists that Provider’s models, and later selections replace the pending choice. Thinking and permission changes made while a Provider switch is pending are staged with it. The switch is applied after the current turn ends, before a queued, retried, or new turn starts; idle tasks switch immediately. Pending settings survive restarts and do not overwrite active settings or Agent defaults until applied. If a deferred switch fails, Agent Bot restores the previous Provider and model. After verified rollback, the card returns to the original settings with an error notice, clears the pending choice, and queued/new turns continue using the original settings. Only failed or unverified rollback pauses further turns and keeps queued prompts for manual recovery. For a custom Provider, Agent Bot uses its OpenAI-compatible `/models` endpoint when available and shows only that Provider's models. A switch keeps the previous model when the target supports it, otherwise selects the target's default model or its first returned model. Providers without a model-list endpoint remain usable: Agent Bot keeps the current or configured model as the single fallback candidate and lets Codex apply it. Agent Bot verifies the task ID, Provider, and model before saving or reporting success. Tasks with history always preserve their remote thread ID and all history, including failed and interrupted Turns. Before the first message, a verified empty task without inherited history or a Goal may switch through a new remote thread, keeping the same local task, conversation, directory, and title. The old thread remains attached until the new Provider/model and persistence succeed; failure keeps the original task. No synthetic Prompt or history rewrite is used. If a switch fails, the previous settings are retained and remote recovery is attempted. If recovery also fails, further turns are blocked until a Provider switch succeeds.
404
408
 
405
409
  For TraeX's built-in `trae` Provider, Agent Bot uses the complete native `model/list` catalog, including model names and reasoning options, without requiring a custom Provider `base_url`. Other custom Providers keep their own model discovery and fallback behavior.
406
410
 
407
- If Provider model discovery fails, settings cards retain the fallback model but show the failure and warn that service availability is unconfirmed. A successful settings change verifies configuration, not a live inference request. App Server runtime errors and native retry notices show the message and any `additionalDetails` in the existing progress card and Preview, including upstream error codes and retry delays when supplied. Error text is displayed literally, duplicate details are omitted, and long reasons remain available in Preview and activity history. Missing details are not guessed, and previously saved snapshots are not backfilled. Retrying does not create another card or end the turn; the final turn notification still determines success, failure, or cancellation.
411
+ If Provider model discovery fails, settings cards retain the fallback model but show the failure and warn that service availability is unconfirmed. A successful settings change verifies configuration, not a live inference request. App Server runtime errors and native retry notices show the message and any `additionalDetails` in the existing progress card and Preview, including upstream error codes and retry delays when supplied. Error text is displayed literally, duplicate details are omitted, and long reasons remain available in Preview and activity history. Missing details are not guessed. Native retries do not create another card or end the turn; the final turn notification still determines success, failure, or cancellation. If the final failure repeats the preceding runtime warning, cards and Preview display it only once; the original execution record is retained.
412
+
413
+ For Codex's specific `unsupported_persisted_item_context` continuation failure, Agent Bot attempts one empty-input continuation in the same task, preserving history and completed tool results without resending your Prompt or images. This creates a new Turn and progress card, not a new task or Fork. If recovery fails, the error remains visible and you can send a message to continue. See the [technical reference](docs/technical-reference.md#responses-continuation-recovery) for recovery limits.
408
414
 
409
415
  Some Responses-compatible Providers omit assistant message phases. Their text stays in the progress timeline instead of being concatenated into a premature final answer. On successful completion, only the last unphased message not followed by a tool start becomes the final answer; explicit `commentary` and `final_answer` phases take precedence. Cards and Preview share this classification. Previously saved snapshots are not automatically rewritten.
410
416
 
411
417
  `feishu.groupNameFormat` defines separate name templates for new Project and Projectless groups, with variables for the operating system, Agent, project, task name, and date. See the [technical reference](docs/technical-reference.md#configuration-model) for the complete format.
412
418
 
413
- If App Server ignores the requested Provider/model, Agent Bot reports the requested and actual settings and verifies restoration on the same thread. It never forks to bypass an upstream limitation. Provider switching releases and resumes only the selected task, preserving its ID and history while other tasks keep running. It does not restart the shared App Server. If the selected task cannot unload, Agent Bot reports the failure and attempts to restore its previous settings. Settings pages can load saved task metadata without first resuming a broken old Provider; ordinary resume/reconnect verifies thread identity and known Provider before allowing a new Turn. Permanent authentication failures are not automatically retried by Agent Bot.
419
+ If App Server ignores the requested Provider/model, Agent Bot reports the requested and actual settings and verifies restoration on the same thread. It never forks to bypass an upstream limitation. Provider switching releases and resumes only the selected task, preserving existing history while other tasks keep running; only verified empty tasks may receive a new remote thread ID. It does not restart the shared App Server. If the selected task cannot unload, Agent Bot reports the failure and attempts to restore its previous settings. Settings pages can load saved task metadata without first resuming a broken old Provider; ordinary resume/reconnect verifies thread identity and known Provider before allowing a new Turn. Permanent authentication failures are not automatically retried by Agent Bot.
414
420
 
415
421
  Agent processes inherit ordinary parent-process variables and their explicit `agents.<name>.env` settings. Before starting an Agent, Agent Bot removes inherited `FEISHU_*` credentials and internal `AGENT_BOT_*` state, then provides only namespaced, non-secret Profile and Lark identity context. `FEISHU_APP_SECRET` is never forwarded to an Agent process.
416
422
 
@@ -424,11 +430,11 @@ Thinking cards use the grouped layout by default: auxiliary Commentary and user
424
430
 
425
431
  Whitespace-only Commentary does not use a pinned message slot. Adjacent omission markers generated by the card are shown once, without removing literal message text or changing saved history.
426
432
 
427
- Turn Preview shows each saved reasoning item in a collapsed panel. A leading Markdown heading or standalone bold title appears directly in the header; expanding shows the remaining summary and content without extra Summary/Content labels or a repeated title. Items without an explicit title keep the Reasoning fallback and all original text. Collapsed panels load their full Markdown on expansion and retain expansion during live updates. Reasoning content and authoritative completed items are stored separately from card activities; this does not change thinking-card titles, grouping, or final replies. Existing saved summaries remain viewable, but previously unrecorded content is not backfilled from Agent history.
433
+ Turn Preview shows saved reasoning with a body in a collapsed panel. A leading Markdown heading or standalone bold title appears directly in the header; expanding shows the remaining summary and content without extra Summary/Content labels or a repeated title. Title-only items remain visible as non-interactive rows without a disclosure arrow or detail request; live updates add a disclosure if a body arrives later. Items without an explicit title keep the Reasoning fallback and all original text. Collapsed panels load their full Markdown on expansion and retain expansion during live updates. Reasoning content and authoritative completed items are stored separately from card activities; this does not change thinking-card titles, grouping, or final replies. Existing saved summaries remain viewable, but previously unrecorded content is not backfilled from Agent history.
428
434
 
429
- Quoted cards and cards inside merged forwards include their available original text and images in the Agent's context.
435
+ Quoted cards and cards inside merged forwards include their available original text and images in the Agent's context. Images and files in forwarded chat records are downloaded as reference attachments, including when you quote the chat record with your own instruction.
430
436
 
431
- When forwarding rich text, images, files, or chat records with an attached message, Agent Bot combines the associated messages into one request: your attached text is the main Prompt, and the forwarded content remains reference material.
437
+ When you quote a message, your current message body is the main Prompt; the quoted content remains reference material regardless of its type. Ordinary Feishu messages, including plain text and links, briefly wait up to 1.5 seconds after acknowledgement for an explicitly linked follow-up from the same sender in the same conversation. A matching follow-up combines them into one request and supplies the card Prompt. Commands do not wait. Quotes of older or already-submitted messages use normal quoting/steering without replacing completed history.
432
438
 
433
439
  File-change summaries display paths as plain text, preserving Windows separators and literal underscores (including `\__init__.py`) instead of interpreting them as Markdown formatting.
434
440
 
package/README.zh.md CHANGED
@@ -279,6 +279,8 @@ agentbot task dismiss [任务] --yes
279
279
 
280
280
  私聊、群正文和话题分别维护当前任务。新话题在执行 `/help`、`/status` 或 `/sessions` 等命令时保持未绑定状态,不会暗中创建分支;收到第一条普通消息后,才会从可识别的原始 turn 创建分支,无法识别来源时则创建全新任务。使用 `/new` 可以直接创建全新话题任务,使用 `/sessions` 可以绑定现有任务;依赖当前任务的命令会提示如何绑定,而不会操作父会话任务。任务运行时发送普通消息会向当前轮次追加指令;需要在本轮结束后独立执行时,使用 `/queue`。
281
281
 
282
+ 排查迟到消息时,复用现有接收审计记录,补充飞书创建时间、SDK 回调时间及事件 ID(上游提供时)。仅首次接收且延迟达到 1 分钟的消息额外记录一条告警,不含消息正文;正常消息和重复投递不增加日志条目。不改变接收、执行或消息顺序;SDK 回调时间不代表 WebSocket 帧抵达本机的时间。
283
+
282
284
  `/release` 会发送当前任务所属 Agent 共享 App Server 的释放卡片,以便在 Codex Desktop 中打开这些任务。卡片会列出并持续刷新阻塞释放的任务名。Agent Bot 不会自动释放:空闲时点击 **Release**,有活动任务时点击 **Release Now** 会中断执行并清除排队 Prompt;**Cancel** 会取消本次释放。释放不会归档或删除任务历史,但会影响该 App Server 已加载的所有任务。
283
285
 
284
286
  Fork 只记录来源任务和分支轮次,不同步完整历史。普通任务和分支任务的 `/turns` 都按需要读取摘要分页,翻页才继续读取较早轮次;分支历史不会越过分支轮次。本地卡片只读取关系索引和当前页 Prompt 摘要,不加载历史工具输出。任务列表只读最近一轮摘要,目录等元数据查询不读轮次;状态与恢复只读最近一轮的完整结果,并复用状态校准结果。活跃检测复用未变化的日志,只增量扫描追加内容。
@@ -293,6 +295,8 @@ Fork 只记录来源任务和分支轮次,不同步完整历史。普通任务
293
295
 
294
296
  `/new` 和 `/newgroup` 默认使用当前会话设置的默认 Agent,也可用 `--agent <名称>` 为本次任务指定已配置的 Agent,不改变源会话的默认值。例如:`/new 修复测试 --agent codex`、`/newgroup 代码审查 --agent traex --dir ~/dev/project`。项目目录仍继承当前任务;选中的 Agent 与当前任务相同时继承运行设置,不同时使用目标 Agent 已保存的 Provider、模型、思考强度和权限默认值。使用 `--dir` 指定其他目录,或使用 `--nodir` 创建无项目目录的任务;`--nodir` 要求选中的 Agent 为 App Server,`~` 表示用户主目录。
295
297
 
298
+ `/dir` 打开文件浏览卡片。`NewFolder` 创建子目录后,原卡片直接进入新目录的第一页,不改变任务工作目录;`Back` 取消创建并回到原目录和页码。
299
+
296
300
  `/file` 支持相对路径、绝对路径和以 `~` 开头的用户目录路径;相对路径以当前任务目录为基准。
297
301
 
298
302
  `/fork` 和 `/forkgroup` 会从已完成的工作创建分支,不会中断正在执行的轮次。`/sessions` 用于跨项目、跨 Provider 管理任务,每页最多显示 10 个任务;切换 Provider 不会让任务从列表或搜索结果中消失。项目菜单提供 `New` 和 `NewGroup`。每个任务的 `SwitchGroup` 会创建新群并绑定同一个任务,保留历史、标题和运行设置,不创建新 Agent 任务,也不 fork,原会话绑定不变。若任务正在 Agent Bot 中执行,本轮回复仍留在原会话,新群发来的消息先排队;正在外部客户端执行的任务需要等待完成后再切换。展开任务后会直接显示最后一个用户 Prompt 的前 50 个字符、更新时间以及任务级操作。运行中任务还会显示独立的 `Stop` 按钮,包括当前任务、其他会话和外部客户端中的任务;点击发送正常的 Agent 中断信号,不切换任务、不结束进程。列表刷新保留搜索条件和页码,并根据 Agent 的实际状态保留或移除 Stop;空闲后才提供 Archive。点击 `Turns` 可查看对应任务的轮次,包括尚未绑定到 Agent Bot 的任务,不会切换当前任务;非当前任务仅供查看,需要先切换任务才能 Reset。`/turns` 用于恢复对话上下文,不会回退本地文件。
@@ -345,7 +349,7 @@ Agent Bot 将用户相关文件保存在仓库之外:
345
349
 
346
350
  `/status` 状态卡片底部也提供 **Preview**,与 Refresh、Stop/Switch 放在同一行。点击可查看正在执行的轮次,空闲时则查看最近一轮;刷新卡片后会更新目标。对于尚未被 Agent Bot 记录的外部任务,打开 Preview 时会从 Agent 按需读取所选轮次并缓存完整执行详情;读取失败可刷新页面重试。没有轮次、无法读取历史且无本地记录,或文件预览服务不可用时不显示按钮。查看其他任务的状态不会切换或接管任务。
347
351
 
348
- 思考卡片底部操作区提供 `Preview` 按钮,可在浏览器中查看本轮已保存的执行时间线,采用紧凑布局并适配手机,思考块直接以内容标题作为折叠标题,展开后显示内容。工具块只有一级折叠,默认只显示有意义的单行命令摘要、简短耗时(如 `58s` 或 `2:32`)和完成状态。展开后先显示以 `$` 开头的完整命令,随后直接显示结果、图片和修改文件。相同的输出和错误日志只显示一次。执行中的 Turn 会通过局部更新实时显示变化,保留手动展开状态、滚动位置和未变化的图片节点,避免图片反复加载;Token 等元数据更新不会替换时间线。折叠工具块和文件变更汇总首屏只加载摘要,展开时才请求命令、日志、图片和文件列表;再次展开复用已加载内容,运行中只刷新已展开且内容变化的详情,加载失败可点击重试。已结束的 Turn 不再建立 SSE 实时连接。“正在等待 Agent 返回进度”只在尚无可显示进度时出现,已有回答、其他可见进展或 Turn 已结束时不再显示。点击文件变更列表或工具文件列表中的文件,可在新标签页预览其当前内容;已删除或不可用的文件保留为路径文字。Turn Preview 与本地文件链接共用相同的签名、访问地址和网络范围。“最终回答”旁提供下载、复制两个图标,导出原始 Markdown(保留格式、代码块和原始链接),而非渲染后的网页内容。下载为 UTF-8 `.md` 文件;复制支持 HTTP/局域网页面的回退方式,并显示成功或失败提示。已结束页面及实时到达的最终回答均可使用。
352
+ 思考卡片底部操作区提供 `Preview` 按钮,可在浏览器中查看本轮已保存的执行时间线,采用紧凑布局并适配手机,思考块直接以内容标题作为折叠标题,展开后显示内容。只有标题而没有正文时保留普通标题行,不显示折叠箭头、不请求详情;实时更新收到正文后才变为可展开项。工具块只有一级折叠,默认只显示有意义的单行命令摘要、简短耗时(如 `58s` 或 `2:32`)和完成状态。展开后先显示以 `$` 开头的完整命令,随后直接显示结果、图片和修改文件。相同的输出和错误日志只显示一次。执行中的 Turn 会通过局部更新实时显示变化,保留手动展开状态、滚动位置和未变化的图片节点,避免图片反复加载;Token 等元数据更新不会替换时间线。折叠工具块和文件变更汇总首屏只加载摘要,展开时才请求命令、日志、图片和文件列表;再次展开复用已加载内容,运行中只刷新已展开且内容变化的详情,加载失败可点击重试。已结束的 Turn 不再建立 SSE 实时连接。“正在等待 Agent 返回进度”只在尚无可显示进度时出现,已有回答、其他可见进展或 Turn 已结束时不再显示。点击文件变更列表或工具文件列表中的文件,可在新标签页预览其当前内容;已删除或不可用的文件保留为路径文字。Turn Preview 与本地文件链接共用相同的签名、访问地址和网络范围。“最终回答”旁提供下载、复制两个图标,导出原始 Markdown(保留格式、代码块和原始链接),而非渲染后的网页内容。下载为 UTF-8 `.md` 文件;复制支持 HTTP/局域网页面的回退方式,并显示成功或失败提示。已结束页面及实时到达的最终回答均可使用。
349
353
 
350
354
  Turn Preview 完整保留已记录的执行内容,不再反复把整轮内容写入数据库。页面按增量实时更新,折叠工具展开时加载完整详情。备份时请保留整个 Profile 数据目录,以同时保存执行记录和任务信息;已有历史仍可查看。
351
355
 
@@ -391,17 +395,19 @@ App Server 任务切换权限时,下一轮会同时应用审批与沙箱策略
391
395
 
392
396
  运行设置卡片末尾以 Markdown 引用块显示当前选项页的一句重点提示,标明作用范围和生效时机。Agent 只改变本聊天或话题中新任务的默认 Agent,不迁移当前任务;模型、思考强度和权限从下一轮生效。Agent 和 Provider 选项只显示标识,去掉重复的展示名称,保留当前与默认状态标记。只有一个 Provider 时,`/provider` 仍会打开运行设置卡片,可以继续切换到 Model、Thinking 和 Permission 页。
393
397
 
394
- 任务正在 Agent Bot 中执行时,切换 Provider 会保存为待生效设置,不中断本轮。运行设置卡片立即显示目标 Provider 和将使用的模型,Model 页切换为该 Provider 的模型列表,可继续选择,以最后选择为准;此时修改思考强度和权限也一起暂存。本轮结束后、排队消息、重试或新轮次开始前实际应用,空闲任务则立即切换。待生效设置重启后保留,应用前不改动实际运行设置或 Agent 默认值。如果延迟切换失败,自动回退之前的 Provider 和模型;确认回退成功后,卡片恢复显示原设置并提示失败原因,清除待生效选择,排队消息和后续轮次继续使用原设置。只有回退也失败或无法确认时才暂停后续轮次、保留排队消息,等待重新选择恢复。对于自定义 Provider,Agent Bot 会在接口可用时读取其 OpenAI 兼容 `/models` 接口,并只展示该 Provider 返回的模型。切换时优先保留目标 Provider 也支持的原模型;否则选择目标 Provider 的默认模型,没有默认模型时选择返回列表中的第一个模型。未提供模型列表接口的 Provider 仍可使用:Agent Bot 会把当前或已配置模型作为唯一候选交给 Codex 应用。Agent Bot 校验任务 ID、Provider 和模型后才保存并提示成功。设置切换始终保留远端线程 ID 和全部历史,包括失败、中断的轮次;绝不新建、Fork、截断或替换线程。尚未保存 rollout 的全新空会话会在取消订阅前明确拒绝切换,不会偷偷替换。切换失败时保留原设置并尝试恢复远端;如果恢复也失败,将阻止后续执行,直到重新切换 Provider 成功。
398
+ 任务正在 Agent Bot 中执行时,切换 Provider 会保存为待生效设置,不中断本轮。运行设置卡片立即显示目标 Provider 和将使用的模型,Model 页切换为该 Provider 的模型列表,可继续选择,以最后选择为准;此时修改思考强度和权限也一起暂存。本轮结束后、排队消息、重试或新轮次开始前实际应用,空闲任务则立即切换。待生效设置重启后保留,应用前不改动实际运行设置或 Agent 默认值。如果延迟切换失败,自动回退之前的 Provider 和模型;确认回退成功后,卡片恢复显示原设置并提示失败原因,清除待生效选择,排队消息和后续轮次继续使用原设置。只有回退也失败或无法确认时才暂停后续轮次、保留排队消息,等待重新选择恢复。对于自定义 Provider,Agent Bot 会在接口可用时读取其 OpenAI 兼容 `/models` 接口,并只展示该 Provider 返回的模型。切换时优先保留目标 Provider 也支持的原模型;否则选择目标 Provider 的默认模型,没有默认模型时选择返回列表中的第一个模型。未提供模型列表接口的 Provider 仍可使用:Agent Bot 会把当前或已配置模型作为唯一候选交给 Codex 应用。Agent Bot 校验任务 ID、Provider 和模型后才保存并提示成功。已有历史的任务切换时保留远端线程 ID 和全部历史,包括失败、中断的轮次。首次发消息前,确认没有历史、分支来源或 Goal 的空会话可通过新建底层线程切换 Provider,本地任务、群聊/话题绑定、目录和标题保持不变;新 Provider/模型及持久化成功前保留原线程,失败仍使用原会话,不发送虚构 Prompt 或改写历史。切换失败时保留原设置并尝试恢复远端;如果恢复也失败,将阻止后续执行,直到重新切换 Provider 成功。
395
399
 
396
400
  `feishu.groupNameFormat` 可分别设置 Project 与 Projectless 新群的名称模板,支持系统、Agent、项目、任务名和日期变量。完整格式说明见[技术参考](docs/technical-reference.zh.md#配置模型)。
397
401
 
398
402
  TraeX 内置的 `trae` Provider 直接使用原生 `model/list` 返回的完整模型列表,保留模型名称和思考强度选项,不要求配置自定义 Provider 的 `base_url`。其他自定义 Provider 的模型发现和降级行为保持不变。
399
403
 
400
- Provider 模型列表读取失败时,设置卡片保留降级候选模型,同时显示失败原因并提示服务可用性未确认。切换成功只代表配置已通过校验,不代表实际模型请求可用。App Server 执行错误和原生重试通知会在同一张思考卡片和 Preview 中显示消息及 `additionalDetails`,包括 Agent 提供的上游错误码和重试等待时间。错误按纯文本展示,重复详情不重复显示,长原因可在 Preview 和思考活动历史中查看。缺少详情时不猜测原因,不自动回填此前保存的快照。重试不会新建卡片或提前结束任务,最终完成通知仍决定成功、失败或取消状态。
404
+ Provider 模型列表读取失败时,设置卡片保留降级候选模型,同时显示失败原因并提示服务可用性未确认。切换成功只代表配置已通过校验,不代表实际模型请求可用。App Server 执行错误和原生重试通知会在同一张思考卡片和 Preview 中显示消息及 `additionalDetails`,包括 Agent 提供的上游错误码和重试等待时间。错误按纯文本展示,重复详情不重复显示,长原因可在 Preview 和思考活动历史中查看。缺少详情时不猜测原因。原生重试不会新建卡片或提前结束任务,最终完成通知仍决定成功、失败或取消状态。最终错误与此前的运行警告相同时,卡片和 Preview 只显示一次,原始执行记录仍然保留。
405
+
406
+ Codex 出现特定的 `unsupported_persisted_item_context` 续接失败时,Agent Bot 会在同一任务中自动尝试一次空输入续接,保留历史和已完成的工具结果,不重发用户的 Prompt 或图片。它会新增一个轮次及思考卡片,不会新建任务或 Fork。恢复失败时仍显示错误,可以发送消息继续。恢复边界见[技术参考](docs/technical-reference.zh.md#responses-续接恢复)。
401
407
 
402
408
  部分 Responses 兼容 Provider 不返回助手消息阶段。这类文本先按顺序显示在进度时间线,不再提前拼接成最终回答;轮次成功结束时,只有最后一条且之后未开始工具调用的未标记消息会成为最终回答。明确的 `commentary` 和 `final_answer` 标记优先。思考卡片和 Preview 使用同一分类结果,不自动改写此前保存的快照。
403
409
 
404
- 如果 App Server 忽略指定的 Provider 或模型,Agent Bot 会报告请求值和实际值,并在同一线程上验证恢复原设置,绝不通过 Fork 绕过上游限制。切换 Provider 只释放并恢复当前任务,保留其 ID 和历史,其他任务可以继续执行;不会因此重启共享 App Server。若目标任务无法卸载,会提示失败并尝试恢复原设置。设置入口可直接加载保存的任务元数据,无需先用故障的旧 Provider 恢复任务;普通恢复和断线重连会验证线程 ID 与已知 Provider,再允许发起新轮次。明确的认证失败不再触发 Agent Bot 自动重试。
410
+ 如果 App Server 忽略指定的 Provider 或模型,Agent Bot 会报告请求值和实际值,并在同一线程上验证恢复原设置,绝不通过 Fork 绕过上游限制。切换 Provider 只处理当前任务并保留已有历史,其他任务可以继续执行;只有确认无历史的空会话允许更换底层线程 ID;不会因此重启共享 App Server。若目标任务无法卸载,会提示失败并尝试恢复原设置。设置入口可直接加载保存的任务元数据,无需先用故障的旧 Provider 恢复任务;普通恢复和断线重连会验证线程 ID 与已知 Provider,再允许发起新轮次。明确的认证失败不再触发 Agent Bot 自动重试。
405
411
 
406
412
  Agent 进程会继承普通父进程变量及其显式配置的 `agents.<name>.env`。启动 Agent 前,Agent Bot 会移除继承的 `FEISHU_*` 凭据和内部 `AGENT_BOT_*` 状态,再仅提供带命名空间的非敏感 Profile 与 Lark 身份上下文;`FEISHU_APP_SECRET` 永远不会传入 Agent 进程。
407
413
 
@@ -417,9 +423,9 @@ Agent Bot 会响应机器人所在群内拥有者发送的普通消息。将 `fe
417
423
 
418
424
  Turn Preview 中每个已保存的思考项默认折叠,开头的 Markdown 标题或独立加粗标题直接显示在折叠栏;展开后显示剩余摘要和正文,不再添加“摘要 / 正文”标签或重复同一标题。没有明确标题时保留“思考”作为标题,原文完整保留。折叠时不加载全文,展开后按需加载 Markdown,实时更新保留展开状态。思考正文及完成事件中的权威内容单独保存,不改变思考卡片的标题、分组或最终回答。旧快照中已保存的摘要仍可查看,此前未记录的正文不会从 Agent 历史自动回填。
419
425
 
420
- 引用卡片或合并转发中的卡片时,Agent 会收到可读取的原始文字和图片。
426
+ 引用卡片或合并转发中的卡片时,Agent 会收到可读取的原始文字和图片。转发聊天记录中的图片、文件会下载为参考附件,引用聊天记录并附上自己的指令时也同样适用。
421
427
 
422
- 转发富文本、图片、文件或聊天记录并附言时,Agent Bot 会将关联消息合并为一次请求:你的附言作为主 Prompt,转发内容保留为参考材料。
428
+ 引用消息时,你当前发送的正文作为主 Prompt,被引用内容保留为参考材料,不按消息类型区分。普通飞书消息(包括文字和链接)在确认收到后,最多短暂等待 1.5 秒;同一用户在同一会话中紧接着显式引用它补充说明时,合并为一次请求,并以附言作为卡片 Prompt。命令不等待;引用旧消息或已提交的消息仍按正常引用和追加逻辑处理,不替换已完成的历史。
423
429
 
424
430
  文件变更摘要中的路径保持普通文本样式,完整保留 Windows 路径分隔符和下划线(包括 `\__init__.py`),不会将其解析为 Markdown 格式。
425
431
 
@@ -2,6 +2,7 @@ import { statSync } from "node:fs";
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
3
  import { appendGeneratedImageMarkdown } from "../utils/generatedImageMarkdown.js";
4
4
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
5
+ import { isResponseContinuationError } from "../runtime/responseErrors.js";
5
6
  import { AppServerRequestError } from "./AppServerConnection.js";
6
7
  import { workspaceSandboxFromConfig, workspaceSandboxFromResponse } from "./AppServerPermissions.js";
7
8
  import { formatCodexError, mapCodexNotification } from "./CodexEventMapper.js";
@@ -170,15 +171,49 @@ export class CodexRuntime {
170
171
  return session;
171
172
  }
172
173
  async startTurn(sessionId, prompt) {
173
- return this.runSessionOperation(sessionId, () => this.startTurnNow(sessionId, prompt));
174
+ return this.runSessionOperation(sessionId, () => {
175
+ this.requireSession(sessionId).responseFailureTurnId = undefined;
176
+ return this.startTurnNow(sessionId, prompt);
177
+ });
178
+ }
179
+ get recoverResponseContinuation() {
180
+ return this.provider.getAgentFamily?.() === "codex"
181
+ ? (sessionId, failedTurnId, signal) => this.recoverResponseContinuationNow(sessionId, failedTurnId, signal)
182
+ : undefined;
174
183
  }
175
- async startTurnNow(sessionId, prompt) {
184
+ async recoverResponseContinuationNow(sessionId, failedTurnId, signal) {
185
+ return this.runSessionOperation(sessionId, async () => {
186
+ signal?.throwIfAborted();
187
+ const session = this.requireSession(sessionId);
188
+ if (session.responseFailureTurnId !== failedTurnId || session.activeTurnId || session.needsResume) {
189
+ throw new Error("模型续接恢复已失效,请发送消息继续。");
190
+ }
191
+ // Consume before starting: a timeout must not create a second continuation.
192
+ session.responseFailureTurnId = undefined;
193
+ const client = await this.client();
194
+ const metadata = await client.request("thread/read", {
195
+ threadId: session.remoteSessionId, includeTurns: false,
196
+ }, SYNC_REQUEST_TIMEOUT_MS);
197
+ const latest = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
198
+ signal?.throwIfAborted();
199
+ if (session.activeTurnId || session.needsResume || metadata.thread.id !== session.remoteSessionId || metadata.thread.status?.type === "active"
200
+ || latest.at(-1)?.id !== failedTurnId || latest.at(-1)?.status !== "failed") {
201
+ throw new Error("任务已发生变化,未自动恢复模型续接。请检查当前任务后继续。");
202
+ }
203
+ const turnId = await this.startTurnNow(sessionId, "", true);
204
+ if (signal?.aborted && session.activeTurnId === turnId)
205
+ await this.cancelTurn(sessionId, turnId);
206
+ signal?.throwIfAborted();
207
+ return turnId;
208
+ });
209
+ }
210
+ async startTurnNow(sessionId, prompt, responseContinuation = false) {
176
211
  const session = this.requireSession(sessionId);
177
212
  const client = await this.client();
178
213
  await this.ensureSessionResumed(session, client);
179
214
  const start = async () => client.request("turn/start", {
180
215
  threadId: session.remoteSessionId,
181
- input: codexUserInput(prompt),
216
+ input: responseContinuation ? [] : codexUserInput(prompt),
182
217
  cwd: session.cwd,
183
218
  model: session.model,
184
219
  effort: session.reasoningEffort,
@@ -197,7 +232,7 @@ export class CodexRuntime {
197
232
  response = await start();
198
233
  }
199
234
  catch (error) {
200
- if (!isInvalidWorkingDirectoryError(error))
235
+ if (responseContinuation || !isInvalidWorkingDirectoryError(error))
201
236
  throw error;
202
237
  this.logger.warn({ error, sessionId: session.localSessionId, cwd: session.cwd }, "App Server rejected the task working directory; refreshing the current thread before retrying.");
203
238
  await this.resumeAppServerSession(session, client);
@@ -617,17 +652,30 @@ export class CodexRuntime {
617
652
  return response.cleared;
618
653
  }
619
654
  async setModel(sessionId, model) {
620
- await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).model = model; });
655
+ await this.runSessionOperation(sessionId, async () => {
656
+ const session = this.requireSession(sessionId);
657
+ session.model = model;
658
+ session.responseFailureTurnId = undefined;
659
+ });
621
660
  }
622
661
  async setReasoningEffort(sessionId, effort) {
623
- await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).reasoningEffort = effort; });
662
+ await this.runSessionOperation(sessionId, async () => {
663
+ const session = this.requireSession(sessionId);
664
+ session.reasoningEffort = effort;
665
+ session.responseFailureTurnId = undefined;
666
+ });
624
667
  }
625
668
  async setPermissionMode(sessionId, mode) {
626
- await this.runSessionOperation(sessionId, async () => { this.requireSession(sessionId).permissionMode = mode; });
669
+ await this.runSessionOperation(sessionId, async () => {
670
+ const session = this.requireSession(sessionId);
671
+ session.permissionMode = mode;
672
+ session.responseFailureTurnId = undefined;
673
+ });
627
674
  }
628
675
  async setExecutionSettings(sessionId, settings, persist) {
629
676
  return this.runSessionOperation(sessionId, async () => {
630
677
  try {
678
+ this.requireSession(sessionId).responseFailureTurnId = undefined;
631
679
  return await this.setExecutionSettingsNow(sessionId, settings, persist);
632
680
  }
633
681
  catch (error) {
@@ -647,22 +695,14 @@ export class CodexRuntime {
647
695
  const metadata = await client.request("thread/read", {
648
696
  threadId: session.remoteSessionId, includeTurns: false,
649
697
  }, SYNC_REQUEST_TIMEOUT_MS);
698
+ if (metadata.thread.id !== session.remoteSessionId)
699
+ throw new Error("App Server 返回了不同的任务 ID,未切换 Provider。");
650
700
  const localActive = await this.localActivityDetector?.activeThreads([session.remoteSessionId]);
651
701
  if (metadata.thread.status?.type === "active" || localActive?.has(session.remoteSessionId) || session.activeTurnId) {
652
702
  throw new Error(PROVIDER_SWITCH_BUSY);
653
703
  }
654
- if (session.turnCount === 0 && !metadata.thread.forkedFromId && !metadata.thread.preview?.trim()) {
655
- try {
656
- const turns = await this.readLatestThreadTurns(client, session.remoteSessionId, "summary");
657
- if (turns.length === 0)
658
- throw new Error(PROVIDER_SWITCH_EMPTY);
659
- }
660
- catch (error) {
661
- if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
662
- || !/missing source rollout|no rollout found/iu.test(error.serverMessage))
663
- throw error;
664
- throw new Error(PROVIDER_SWITCH_EMPTY, { cause: error });
665
- }
704
+ if (await this.isEmptySettingsSession(client, session, metadata.thread)) {
705
+ return this.replaceEmptySettingsSession(client, session, settings, persist);
666
706
  }
667
707
  const previous = { ...session };
668
708
  const restoreProvider = previous.modelProvider?.trim()
@@ -726,6 +766,78 @@ export class CodexRuntime {
726
766
  }
727
767
  return session;
728
768
  }
769
+ async isEmptySettingsSession(client, session, thread) {
770
+ if (thread.id !== session.remoteSessionId || thread.preview !== "" || thread.forkedFromId
771
+ || (session.turnCount === undefined && thread.forkedFromId !== null)
772
+ || session.activeTurnId || (session.turnCount ?? 0) > 0 || session.knownTurnIds.size > 0
773
+ || session.terminalTurnIds.size > 0 || (thread.turns?.length ?? 0) > 0)
774
+ return false;
775
+ try {
776
+ if ((await this.readLatestThreadTurns(client, session.remoteSessionId, "summary")).length > 0)
777
+ return false;
778
+ }
779
+ catch (error) {
780
+ if (!(error instanceof AppServerRequestError) || error.method !== "thread/turns/list"
781
+ || !/missing source rollout|no rollout found|not materialized yet/iu.test(error.serverMessage))
782
+ throw error;
783
+ }
784
+ if (!["idle", "notLoaded"].includes(thread.status?.type ?? ""))
785
+ throw new Error(PROVIDER_SWITCH_EMPTY);
786
+ const { goal } = await client.request("thread/goal/get", {
787
+ threadId: session.remoteSessionId,
788
+ }, CONTROL_REQUEST_TIMEOUT_MS);
789
+ if (goal !== null)
790
+ throw new Error(PROVIDER_SWITCH_EMPTY);
791
+ return true;
792
+ }
793
+ async replaceEmptySettingsSession(client, session, settings, persist) {
794
+ const oldId = session.remoteSessionId;
795
+ let replacementId;
796
+ try {
797
+ // Keep the original attachment until target verification and persistence both succeed.
798
+ const response = await client.request("thread/start", {
799
+ cwd: session.cwd, modelProvider: settings.modelProvider, model: settings.model, threadSource: "user",
800
+ ...threadLifecycleParams(session.cwd), ...permissionParams(settings.permissionMode),
801
+ }, SESSION_REQUEST_TIMEOUT_MS);
802
+ if (!response.thread.id || response.thread.id === oldId)
803
+ throw new Error("App Server 未创建独立的空任务,未保存设置。");
804
+ replacementId = response.thread.id;
805
+ assertProviderSettingsApplied(response, settings);
806
+ if (session.title)
807
+ await client.request("thread/name/set", { threadId: replacementId, name: session.title }, CONTROL_REQUEST_TIMEOUT_MS);
808
+ const { thread } = await client.request("thread/read", { threadId: oldId, includeTurns: false }, SYNC_REQUEST_TIMEOUT_MS);
809
+ const active = await this.localActivityDetector?.activeThreads([oldId]);
810
+ if (active?.has(oldId) || !await this.isEmptySettingsSession(client, session, thread))
811
+ throw new Error(PROVIDER_SWITCH_BUSY);
812
+ if (this.attachedClient !== client)
813
+ throw new Error("App Server 连接已变化,请重试 Provider 切换。");
814
+ const candidate = this.makeSession({
815
+ localSessionId: session.localSessionId, agentName: session.agentName, cwd: session.cwd, title: session.title, ...settings,
816
+ }, response, settings.reasoningEffort);
817
+ candidate.title = session.title;
818
+ await persist?.(candidate, { emptyRemoteSessionId: oldId });
819
+ Object.assign(session, candidate, {
820
+ needsResume: this.attachedClient !== client, settingsRecoveryError: undefined, remoteSummary: undefined,
821
+ });
822
+ }
823
+ catch (error) {
824
+ if (replacementId)
825
+ await this.releaseUnusedSettingsThread(client, replacementId);
826
+ throw new ProviderSwitchError(`Provider 切换失败:${providerSwitchFailure(error)} 原会话和设置已保留。`, !session.needsResume && !session.settingsRecoveryError, { cause: error });
827
+ }
828
+ if (![...this.sessions.values()].some(other => other.remoteSessionId === oldId)) {
829
+ await this.releaseUnusedSettingsThread(client, oldId);
830
+ }
831
+ return session;
832
+ }
833
+ async releaseUnusedSettingsThread(client, threadId) {
834
+ try {
835
+ await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
836
+ }
837
+ catch (error) {
838
+ this.logger.warn({ error, threadId }, "Could not release an unused empty settings thread.");
839
+ }
840
+ }
729
841
  async detachThreadForSettings(client, session, onDetach) {
730
842
  const current = await client.request("thread/read", {
731
843
  threadId: session.remoteSessionId, includeTurns: false,
@@ -1236,6 +1348,9 @@ export class CodexRuntime {
1236
1348
  session.activeTurnStartedAt = undefined;
1237
1349
  session.terminalTurnIds.add(mapped.turnId);
1238
1350
  session.messagePhases.clear();
1351
+ session.responseFailureTurnId = mapped.status === "failed" && mapped.error
1352
+ && this.provider.getAgentFamily?.() === "codex" && isResponseContinuationError(mapped.error)
1353
+ ? mapped.turnId : undefined;
1239
1354
  if (mapped.status === "cancelled") {
1240
1355
  this.emit({ type: "turn_cancelled", sessionId, turnId: mapped.turnId });
1241
1356
  }
@@ -1406,6 +1521,7 @@ export class CodexRuntime {
1406
1521
  session.turnCount += 1;
1407
1522
  }
1408
1523
  session.activeTurnId = turnId;
1524
+ session.responseFailureTurnId = undefined;
1409
1525
  session.activeTurnStartedAt = startedAt;
1410
1526
  session.planContinuationTurnId = undefined;
1411
1527
  session.modeChangeRequestIds.clear();
@@ -1440,6 +1556,9 @@ export class CodexRuntime {
1440
1556
  session.activeTurnStartedAt = undefined;
1441
1557
  session.terminalTurnIds.add(turn.id);
1442
1558
  session.messagePhases.clear();
1559
+ const failure = formatCodexError(turn.error);
1560
+ session.responseFailureTurnId = turn.status === "failed" && failure
1561
+ && this.provider.getAgentFamily?.() === "codex" && isResponseContinuationError(failure) ? turn.id : undefined;
1443
1562
  if (turn.status === "interrupted") {
1444
1563
  this.emit({ type: "turn_cancelled", sessionId: session.localSessionId, turnId: turn.id });
1445
1564
  return;
@@ -1449,7 +1568,7 @@ export class CodexRuntime {
1449
1568
  type: "turn_failed",
1450
1569
  sessionId: session.localSessionId,
1451
1570
  turnId: turn.id,
1452
- message: formatCodexError(turn.error) ?? "App Server turn failed.",
1571
+ message: failure ?? "App Server turn failed.",
1453
1572
  });
1454
1573
  return;
1455
1574
  }
@@ -1499,7 +1618,7 @@ export class CodexRuntime {
1499
1618
  workspaceSandboxPolicy: workspaceSandboxFromResponse(response.sandbox),
1500
1619
  rolloutPath: stringValue(response.thread.path)?.trim() || undefined,
1501
1620
  turnCount: "remoteSessionId" in input ? undefined : 0,
1502
- knownTurnIds: new Set(),
1621
+ knownTurnIds: new Set("lastTurnId" in input && input.lastTurnId ? [input.lastTurnId] : []),
1503
1622
  };
1504
1623
  }
1505
1624
  requireSession(sessionId) {
@@ -1572,6 +1691,7 @@ export class CodexRuntime {
1572
1691
  for (const session of this.sessions.values()) {
1573
1692
  session.needsResume = true;
1574
1693
  session.planContinuationTurnId = undefined;
1694
+ session.responseFailureTurnId = undefined;
1575
1695
  const turnId = session.activeTurnId;
1576
1696
  if (!turnId)
1577
1697
  continue;