diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 2a9957eb7d..46e85bfa99 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -26,7 +26,7 @@ Stable Contracts and Security Control Plane 的边界以 |---|---|---|---|---|---| | 1 | 接口与入口层 | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `OpenBitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | 产品宿主、命令、UI 入口、协议接口和跨形态测试 | desktop、CLI、server、relay、Web UI、mobile web、installer、E2E、`acp`、`sdk-host` | 最近的本地 `AGENTS.md`;[interfaces](src/crates/interfaces/AGENTS.md) | | 2 | 产品组装层 | `src/crates/assembly` | 兼容导出、产品能力选择、product-full 接线、不可变内置 Agent 内容、adapter/service 注册和生态无关的来源协调 | `agent-content`, `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | -| 3 | 适配层 | `src/crates/adapters` | AI/transport/WebDriver 协议 adapter、外部 AI work source adapter(OpenCode/Claude Code/Codex)和外部 provider 转换 | `agent-runtime-ipc`、`ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | +| 3 | 适配层 | `src/crates/adapters` | AI/transport/WebDriver 协议 adapter、外部 AI work source adapter(OpenCode/Claude Code/Codex/Zcode)和外部 provider 转换 | `agent-runtime-ipc`、`ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `zcode-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `relay-service`, `page-function-runtime`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | 执行原语层 | `src/crates/execution` | 可移植 Agent Runtime、命名工作流策略、stream、插件运行时客户端、typed-service、tool-contract、tool-group 和 tool-execution 构件 | `agent-runtime`, `agent-workflows`, `agent-stream`, `tool-contracts`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | 稳定契约与产品领域层 | `src/crates/contracts` | 跨层共享 DTO、事件形状、runtime port、产品领域契约和策略 | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | diff --git a/AGENTS.md b/AGENTS.md index bb0ae4ba77..ea4b66a8e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Keep crate dependencies inside each layer to the smallest set needed. |---|---|---|---|---|---| | 1 | Interfaces and entrypoints | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `OpenBitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | Product hosts, commands, UI entrypoints, protocol interfaces, and cross-surface tests | desktop, CLI, server, relay, Web UI, mobile web, installer, E2E, `acp`, `app-server`, `sdk-host` | nearest local `AGENTS.md`; [interfaces](src/crates/interfaces/AGENTS.md) | | 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, immutable built-in Agent content, adapter/service registration, and ecosystem-neutral source coordination | `agent-content`, `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | -| 3 | Adapters | `src/crates/adapters` | AI/transport/WebDriver protocol adapters, external AI work source adapters (OpenCode/Claude Code/Codex), and external-provider translation | `agent-runtime-ipc`, `ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | +| 3 | Adapters | `src/crates/adapters` | AI/transport/WebDriver protocol adapters, external AI work source adapters (OpenCode/Claude Code/Codex/Zcode), and external-provider translation | `agent-runtime-ipc`, `ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `zcode-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `miniapp-market-service`, `relay-service`, `page-function-runtime`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | Execution primitives | `src/crates/execution` | Portable Agent Runtime, named-workflow policy, stream, plugin runtime client, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-workflows`, `agent-stream`, `tool-contracts`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | Stable contracts and product domains | `src/crates/contracts` | Shared DTOs, event shapes, runtime ports, and product domain contracts/policies | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | diff --git a/Cargo.lock b/Cargo.lock index b2b15f946f..73c0bb2251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6186,12 +6186,14 @@ dependencies = [ name = "openbitfun-codex-adapter" version = "1.0.6" dependencies = [ + "chrono", "dirs 6.0.0", "dunce", "hex", "openbitfun-product-domains", "openbitfun-services-core", "openbitfun-static-hook-support", + "rusqlite", "serde_json", "sha2", "tempfile", @@ -6265,6 +6267,7 @@ dependencies = [ "openbitfun-services-core", "openbitfun-services-integrations", "openbitfun-tool-packs", + "openbitfun-zcode-adapter", "regex", "reqwest", "rusqlite", @@ -7015,6 +7018,19 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "openbitfun-zcode-adapter" +version = "1.0.6" +dependencies = [ + "dirs 6.0.0", + "hex", + "openbitfun-product-domains", + "rusqlite", + "serde_json", + "sha2", + "tempfile", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 023815ee21..26f9ee293f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "src/crates/adapters/opencode-plugin-host", "src/crates/adapters/claude-code-adapter", "src/crates/adapters/codex-adapter", + "src/crates/adapters/zcode-adapter", "src/crates/adapters/static-hook-support", "src/crates/adapters/webdriver", "src/crates/adapters/transport", diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index e6ea5c8e75..6f301da6cb 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -44,6 +44,7 @@ Codex 与 DeepSeek Harness 中受支持的安全声明,并在用户显式确 | 内容与生态 | 此页发现范围 | 原生副本导入 | 使用与限制 | |---|---|---|---| +| Session:Codex、Zcode | 本地 Desktop 按工作区自动发现已保存会话 | 打开来源时刷新只读历史,首次发送创建独立原生分支;可移除和重新添加来源 | Zcode 读取本地 SQLite 主任务历史;Remote workspace、Peer、IM 和 Detached Dispatch 的来源续聊入口未提供 | | Skill:Claude Code、Codex、OpenCode、Pi、DSH | 已接入各自受支持目录/文件子集;不代表完整包或动态配置解析 | 本地 Desktop 可复制受支持格式;按主机导入版本保留旧格式路径 | 未导入外部 Skill 不进入原生运行目录;导入后由 Skill owner 选择 | | MCP:Claude Code、Codex、OpenCode、DSH | 已接入安全声明子集;Pi 没有 provider | 本地 Desktop、根 CLI 可规划并复制;副本默认禁用 | 原生连接状态由 MCP owner 确认;前三类另有持续兼容路径,DSH 发现不意味着 DSH 持续运行兼容 | | Hook:五种生态 | 静态来源和事件声明 | 仅 Claude Code/Codex 的受支持同步 command,经审阅导入 | 目录本身不执行;原生 Hook 仍受开关控制;Pi/DSH/OpenCode 目录只读 | @@ -70,6 +71,83 @@ MCP 副本的连接状态不借用外部来源 activation,页面明确“尚 其中远程工作区和 Peer 为前端能力门禁模拟,不是 SSH、IM、Peer 或 Detached Dispatch 端到端证据;手工步骤见 [Web UI 使用说明](../../../src/web-ui/README.zh-CN.md#外部导入状态检查)。 +### Zcode 会话来源与原生副本 + +`adapters/zcode-adapter` 独立读取 `~/.zcode/cli/db/db.sqlite` 的 `session`、`message`、`part`。 +格式基线是 Zcode 3.15.1 所附 CLI 0.16.9 的本地记录。SQLite 只读事务包含已提交的 WAL 数据, +不复制正在写入的数据库主文件,不读取凭据,也不从 `model-io-*.jsonl` 请求日志重建历史。 +不存在的数据库返回空目录;权限、损坏或缺少必要列返回明确错误,不修改或重建来源。 +旧记录缺少 `sequence` 时按时间和 ID 排序,未知可选列保留兼容。 + +仅列出 `parent_id IS NULL` 的主任务。隐藏的继承上下文、系统和 Todo 提醒不生成用户轮次; +可见消息按 `parentID` 归入原始用户输入,工具参数、结果和失败状态作为非执行历史保留。 +进行中的工具保留中断状态。附件仅保留名称引用,并返回和持久化导入提示;不复制来源附件文件。 +不支持的消息角色、工具状态、损坏记录和超出读取预算的历史明确失败,不以空历史伪装成功。 + +产品装配按来源选择 Codex 或 Zcode reader,复用工作区映射、原子写入及 Session owner; +续聊分支使用宿主当前配置的主模型。来源生态与 Session ID 共同参与匹配,避免跨生态混认。 +本功能开发阶段的手动列表、计划、执行和取消命令已随旧界面移除;当前入口使用原生会话列表、 +历史读取和分叉协议。已有手动导入副本、旧索引及历史修复路径继续兼容,不删除用户数据。 +Zcode 会话读取不意味着其 ACP 运行时、设置、Skill、MCP 或 Hook 已接入,其余类别仍显示真实支持状态。 + +会话自动发现由 `app.external_session_discovery` 按来源选择,默认全部关闭。生态兼容页提供 +各来源开关;工作区打开或启动后的原生会话列表首次读取时,由服务主机在后台扫描已开启来源。 +会话列表入口从主机的集中式会话存储目录解析所属项目及关联工作树,重新打开工作区按主机记录的 +最近访问版本重新扫描;开关保存通知已挂载的会话列表重新读取,绕过保存前的短期缓存。 +工作区归属按来源目录匹配最近的已登记本地工作区。后台任务不依赖控制端在线;会话元数据分页 +通过可选的 `discoveryPending` / `discoveryWarnings` 投影进度和非致命错误,已有原生会话始终可读。 +服务主机合并同一批工作区打开请求的来源目录扫描,Codex 读取器按文件大小和修改时间复用已解析的 +有界头部,不重复读取未变化文件。启动仅将会话标题、来源和归属写入集中会话目录的 +`.external-session-index.json`;该私有索引由现有持久化入口合并到元数据分页,旧主机忽略此文件, +既有原生 Session 格式不变。改名、归档和删除只更新索引;删除保留抑制记录,重新扫描不复活条目。 +源会话的移除动作显示为“从 OpenBitFun 移除”,不修改外部原始记录或已有原生分支。生态兼容页 +列出当前主机上已移除的来源,用户主动重新添加时按工作区 ID 和来源身份校验,恢复同一个会话 ID、 +自定义名称和缓存;恢复操作幂等,迟到的重试不会撤销之后的归档。旧重复项恢复为隐藏别名。 +源会话与续聊分支的归档、移除及删除互不级联;源应用的归档状态不覆盖 OpenBitFun 的本地选择。 +分支中打开已移除的来源会显示原因并提供重新添加;无法读取来源不会冒充删除或清空历史。 +重新添加不读取来源正文,仍仅在打开来源会话时刷新。Peer 页面明确禁用来源恢复,CLI Peer 和 +旧主机返回不支持状态;SSH 工作区在解析本地存储前拒绝恢复请求。 +自动发现创建跟随来源的只读视图。首次打开重新验证来源身份、工作区和存储归属,在 +`.external-history//.json` 原子保存不可变缓存,再发布索引指针; +不创建原生 Session。内容版本是 adapter 版本与标准化轮次的摘要,独立于解析器的 `sourceRevision`。 +轮次 ID 带内容版本,已有分页和分叉请求据此读取实际看过的版本;旧缓存保留以支持迟到客户端。 +来源追加、回退或重写生成新版本。缓存缺失时来源失败明确报错;已有缓存则保留并标记来源不可用, +不发布空历史、不删除条目。元数据及工作区正文搜索不读取来源正文;正文搜索排除跟随视图并返回说明。 +Web UI 每次打开来源会话时读取一次来源;停留、输入、窗口恢复可见及查看原生分支都不触发刷新。 +首次发送使用现有分叉入口复制已显示版本并切换到原生分支,之后使用主机当前模型与权限配置。 +分支以 `externalBranchOf` 保留来源及版本,来源仍可独立更新,后续内容不自动混入分支。 +编辑或回退来源历史须先显式分叉;共享持久化及运行时入口拒绝直接修改跟随视图。 +旧版自动导入复用原会话 ID,发现时不读取正文、不额外生成条目;曾经误生成的来源重复项保留为隐藏别名,已有分支链接仍有效。 +首次打开才核对旧原生记录与来源内容,未编辑的记录转为跟随视图,原文件保留;已有本地续聊或编辑的记录继续独立显示。 +通过核对后记录本地历史摘要,后续来源改写不会误判为本地编辑;旧主机再次修改本地历史时重新核对。 +跟随条目保留旧 `externalHistoryPending` 标记,旧版主机仍能物化副本;手动导入的原生副本始终独立。 +来源输入区采用当前主机默认权限,用户选择先存于本地输入状态,创建分支后才写入分支,不恢复来源运行时。私有索引新字段可选, +不改变现有原生会话 invoke 请求形状;不理解跟随视图的客户端直接提交时收到明确的只读/需要分叉错误。 +改名、归档、删除意图独立于缓存更新,既有完整副本及历史版本不自动清理。 +来源标题由生态 adapter 读取:Codex 优先使用 `state_*.sqlite` 的 `threads.name`,随后是 +`session_index.jsonl` 最新名称、数据库 `title`、rollout 头部名称及有效用户输入摘要;旧版 +`sqlite/` 数据库路径及缺少 `name` 的表继续可读。标题快照按数据库、WAL 与名称索引的修改时间 +和大小缓存,一批发现只读一次;可选名称源不可读时保留目录、报告诊断并保留上次已知名称。 +Zcode 继续使用来源 `session.title`。私有索引以可选字段保留来源标题及旧摘要,原生元数据读取 +投影正确名称,不在启动时批量重写 Session。仅在名称仍等于原来源名称或可验证旧摘要时更新, +手动改名标记阻止后续覆盖;旧索引缺少新字段仍可往返,公开候选 DTO 形状不变。 +Codex 恢复或重试可能留下多份相同线程 ID 的 rollout。目录在分页和计数前按线程去重, +优先使用 `threads.rollout_path`,旧数据库缺少该字段时按来源记录时间选择;旧文件候选 ID +在读取正文时也解析到当前记录,避免导入已中断的早期副本。不会拼接已放弃的重试分支。 +对旧 `codex-rollout-jsonl-v1` 中只有用户输入、没有助手文本或工具历史的副本,显式读取时 +检查全部原有轮次仍带来源标记、用户输入与新来源前缀相同且没有本地新增内容,再补齐历史。 +修复保留原生名称、归属及用户元数据;存储服务先保留原副本和目标副本的恢复记录,再逐个 +原子写入轮次,最后提交元数据。中断可从恢复记录继续;检测到本地编辑时停止覆盖。 +该修复不在启动索引阶段读取正文,也不持续同步已导入的完整副本。 +工作区菜单按来源筛选,导入记录带来源标签;筛选覆盖完整元数据分页,不加载历史正文。 +来源切换从折叠的前五行开始,元数据加载不改变展开级别;只有原有展开控件增加可见行数。 +开关属于服务主机,界面筛选属于设备与工作区;旧主机缺少配置字段时开关不可用。 +SSH 工作区自动发现明确不支持,不扫描控制端目录。后台发现仍由目标主机持有。 +Peer 的 Relay 历史游标尚不能固定来源版本,Web UI 明确拒绝打开或直接续聊跟随视图;已创建的原生 +分支继续走既有 Peer 路径。远程控制、CLI 和 Detached Dispatch 未提供自动分叉的入口时,共享 +运行时返回明确的只读错误,不能把来源视图当成可写会话。控制端不参与来源读取或缓存写入。 +本实现通过本地存储、DOM 和设备切换夹具验证;未完成真实远程主机或原生 WebView 交互验证。 + ## 0. 当前 MCP 快照导入契约(C0a) 快照导入是显式复制,不是持续同步,也不改变现有外部 MCP 兼容来源。Desktop 与根 CLI 只负责展示脱敏预览并发送 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 96b2c8c532..ad00edda46 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -196,7 +196,7 @@ block-beta | Shared frontend | `src/shared` | `shared` | | Interfaces | `src/crates/interfaces/*` | `acp`、`app-server`、`app-server-client`、`app-server-protocol`、`sdk-host` | | Assembly | `src/crates/assembly/*` | `agent-content`、`core`、`external-sources`、`product-capabilities` | -| Adapters | `src/crates/adapters/*` | `agent-runtime-ipc`、`ai-adapters`、`claude-code-adapter`、`codex-adapter`、`dsh-adapter`、`opencode-adapter`、`static-hook-support`、`transport`、`webdriver` | +| Adapters | `src/crates/adapters/*` | `agent-runtime-ipc`、`ai-adapters`、`claude-code-adapter`、`codex-adapter`、`zcode-adapter`、`dsh-adapter`、`opencode-adapter`、`static-hook-support`、`transport`、`webdriver` | | Services | `src/crates/services/*` | `services-core`、`services-integrations`、`miniapp-market-service`、`skin-market-service`、`relay-service`、`page-function-runtime`、`terminal` | | Execution | `src/crates/execution/*` | `agent-runtime`、`agent-workflows`、`agent-stream`、`plugin-runtime-client`、`runtime-services`、`tool-contracts`、`tool-provider-groups`、`tool-execution` | | Contracts | `src/crates/contracts/*` | `core-types`、`events`、`runtime-ports`、`product-domains` | diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 972d4c9f4b..cf04073de3 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**324 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**326 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 -OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **324** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **326** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -27,20 +27,20 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **674** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **676** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **674** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **676** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **50**、委托 **62**、需交互 **212**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **50**、委托 **62**、需交互 **214**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;智能体不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 324 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 326 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读智能体只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **50 direct**, **62 delegated**, **212 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **50 direct**, **62 delegated**, **214 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 324 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 326 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index d61e08c3f7..e303434d47 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "a0070347f9527a6eddeb84b5cd0a93d345c378aa008ce857976f02a9f4bd3d2f", + "digest": "1cd850650aa8b8df165b6fe69c90f060c240169f4becf163ecaba3fbe037000b", "ownerDigest": "b96bf9292a92548facbd835b8b89c4865bfdd984b356c9001995f552c6a34f51", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 324, + "documentedItems": 326, "controlCoverage": { "direct": 50, "delegated": 62, - "interactive": 212, + "interactive": 214, "unsupported": 0 } }, @@ -7864,6 +7864,8 @@ "itemIds": [ "source-discovery", "source-policy", + "codex-session-import", + "zcode-session-import", "prompts-commands", "subagents", "mcp-import", @@ -8003,6 +8005,98 @@ "actionId": "surface.ecosystemCompatibility.open" } }, + { + "id": "feature.ecosystem-compatibility:open:codex-session-import", + "capabilityId": "feature.ecosystem-compatibility", + "itemIds": [ + "codex-session-import" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "action", + "actionId": "surface.ecosystemCompatibility.open" + } + }, + { + "id": "feature.ecosystem-compatibility:open:zcode-session-import", + "capabilityId": "feature.ecosystem-compatibility", + "itemIds": [ + "zcode-session-import" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "action", + "actionId": "surface.ecosystemCompatibility.open" + } + }, { "id": "feature.ecosystem-compatibility:open:prompts-commands", "capabilityId": "feature.ecosystem-compatibility", @@ -23439,8 +23533,8 @@ "categoryId": "automation", "titleZh": "外部导入", "titleEn": "External Import", - "summaryZh": "查看五类外部 AI 生态的内容与状态,导入受支持的 Skill、MCP 和 Hook 副本,并查看 Command、Tool、子智能体的兼容使用状态。", - "summaryEn": "Inspect content and status from five external AI ecosystems, import supported Skill, MCP and Hook copies, and review compatibility usage for commands, tools and subagents.", + "summaryZh": "查看外部 AI 生态的内容与状态,导入受支持的 Skill、MCP、Hook,以及 Codex、Zcode 会话,并在 OpenBitFun 中继续对话。", + "summaryEn": "Inspect external AI content and status, import supported Skills, MCP, Hooks, and Codex or Zcode sessions, and continue conversations in OpenBitFun.", "keywordsZh": [ "外部导入", "生态兼容", @@ -23449,7 +23543,10 @@ "OpenCode", "Claude Code", "Codex", - "导入" + "导入", + "Zcode", + "智谱", + "会话导入" ], "keywordsEn": [ "external import", @@ -23460,19 +23557,24 @@ "OpenCode", "Claude Code", "Codex", - "import" + "import", + "Zcode", + "Z.ai", + "session import" ], "highlightsZh": [ "按已识别与更多应用浏览;可发现表示支持查找,分类状态表示当前结果", "自动发现只控制列表更新;关闭后保留结果,仍可手动刷新,已接入的功能照常运行", "审阅并导入受支持的 Skill、MCP 与 Hook;Command、Tool、子智能体暂无副本导入", - "已导入表示副本已保存;在对应管理页确认启用与连接" + "已导入表示副本已保存;在对应管理页确认启用与连接", + "Codex、Zcode 会话按工作区自动发现;打开来源时刷新历史,首次发送创建独立分支,可移除和重新添加来源" ], "highlightsEn": [ "Browse identified and more apps; discovery coverage and current category results are separate", "Automatic discovery controls list updates; pausing retains results and allows manual refresh without changing active integrations", "Review and import supported Skills, MCP and Hooks; command, tool and subagent copy import is unavailable", - "Imported means a copy was saved; confirm activation and connection in the relevant settings" + "Imported means a copy was saved; confirm activation and connection in the relevant settings", + "Discover Codex and Zcode sessions by workspace, refresh history on open, continue in a new branch on first send, and remove or restore source views" ], "items": [ { @@ -23497,6 +23599,28 @@ "reasonEn": "“Enable or disable each source and choose managed, preview, or read-only policy” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "codex-session-import", + "titleZh": "发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊", + "titleEn": "Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊”由用户在生态兼容页开启发现或选择已移除的来源。打开来源时刷新历史,首次发送创建独立分支。归档、移除和分支删除各自独立,原应用历史保持不变。智能体打开入口,由用户选择具体会话。", + "reasonEn": "“Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches” requires the user to enable discovery or select a removed source in ecosystem compatibility. Opening a source refreshes its history and first send creates an independent branch. Archives, source removal and branch deletion stay independent; original history is preserved. The Agent opens the entry for the user to choose a session." + } + }, + { + "id": "zcode-session-import", + "titleZh": "发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊", + "titleEn": "Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊”由用户在生态兼容页开启发现或选择已移除的来源。打开来源时刷新历史,首次发送创建独立分支。归档、移除和分支删除各自独立,原应用历史保持不变。智能体打开入口,由用户选择具体会话。", + "reasonEn": "“Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches” requires the user to enable discovery or select a removed source in ecosystem compatibility. Opening a source refreshes its history and first send creates an independent branch. Archives, source removal and branch deletion stay independent; original history is preserved. The Agent opens the entry for the user to choose a session." + } + }, { "id": "prompts-commands", "titleZh": "展开外部 Prompt/Command 内容并处理与原生命令的冲突", @@ -23616,24 +23740,35 @@ "Claude Code", "Codex", "导入", + "Zcode", + "智谱", + "会话导入", "external import", "external imports", "ecosystem compatibility", "external AI", "configuration migration", "import", + "Z.ai", + "session import", "按已识别与更多应用浏览;可发现表示支持查找,分类状态表示当前结果", "自动发现只控制列表更新;关闭后保留结果,仍可手动刷新,已接入的功能照常运行", "审阅并导入受支持的 Skill、MCP 与 Hook;Command、Tool、子智能体暂无副本导入", "已导入表示副本已保存;在对应管理页确认启用与连接", + "Codex、Zcode 会话按工作区自动发现;打开来源时刷新历史,首次发送创建独立分支,可移除和重新添加来源", "Browse identified and more apps; discovery coverage and current category results are separate", "Automatic discovery controls list updates; pausing retains results and allows manual refresh without changing active integrations", "Review and import supported Skills, MCP and Hooks; command, tool and subagent copy import is unavailable", "Imported means a copy was saved; confirm activation and connection in the relevant settings", + "Discover Codex and Zcode sessions by workspace, refresh history on open, continue in a new branch on first send, and remove or restore source views", "发现 OpenCode、Claude Code、Codex 等外部 AI 来源与可用内容", "Discover OpenCode, Claude Code, Codex, and other external AI sources and content", "按来源启用或停用集成,并选择管理、预览或只读策略", "Enable or disable each source and choose managed, preview, or read-only policy", + "发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊", + "Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches", + "发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊", + "Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches", "展开外部 Prompt/Command 内容并处理与原生命令的冲突", "Expand external prompt/command content and resolve conflicts with native commands", "启用外部智能体,处理同名冲突并绑定模型", diff --git a/docs/interactive-capabilities/capabilities/feature.ecosystem-compatibility.md b/docs/interactive-capabilities/capabilities/feature.ecosystem-compatibility.md index b03d445960..f7a0054ba1 100644 --- a/docs/interactive-capabilities/capabilities/feature.ecosystem-compatibility.md +++ b/docs/interactive-capabilities/capabilities/feature.ecosystem-compatibility.md @@ -11,9 +11,9 @@ title_en: "External Import" > 功能 / Feature -查看五类外部 AI 生态的内容与状态,导入受支持的 Skill、MCP 和 Hook 副本,并查看 Command、Tool、子智能体的兼容使用状态。 +查看外部 AI 生态的内容与状态,导入受支持的 Skill、MCP、Hook,以及 Codex、Zcode 会话,并在 OpenBitFun 中继续对话。 -Inspect content and status from five external AI ecosystems, import supported Skill, MCP and Hook copies, and review compatibility usage for commands, tools and subagents. +Inspect external AI content and status, import supported Skills, MCP, Hooks, and Codex or Zcode sessions, and continue conversations in OpenBitFun. ## 完整功能清单 / Everything included @@ -21,6 +21,10 @@ Inspect content and status from five external AI ecosystems, import supported Sk - Discover OpenCode, Claude Code, Codex, and other external AI sources and content - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 按来源启用或停用集成,并选择管理、预览或只读策略 - Enable or disable each source and choose managed, preview, or read-only policy +- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊 + - Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches +- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊 + - Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 展开外部 Prompt/Command 内容并处理与原生命令的冲突 - Expand external prompt/command content and resolve conflicts with native commands - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 启用外部智能体,处理同名冲突并绑定模型 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index a2a26fac33..f37e141f2d 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,12 +1,12 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "a0070347f9527a6eddeb84b5cd0a93d345c378aa008ce857976f02a9f4bd3d2f", - "count": 212, + "catalogDigest": "1cd850650aa8b8df165b6fe69c90f060c240169f4becf163ecaba3fbe037000b", + "count": 214, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, - "unstructuredInteraction": 180, + "unstructuredInteraction": 182, "visualSelection": 23 }, "entries": [ @@ -1652,6 +1652,44 @@ "command:set_external_source_conflict_choice_command" ] }, + { + "capabilityId": "feature.ecosystem-compatibility", + "itemId": "codex-session-import", + "titleZh": "发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊", + "titleEn": "Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊”由用户在生态兼容页开启发现或选择已移除的来源。打开来源时刷新历史,首次发送创建独立分支。归档、移除和分支删除各自独立,原应用历史保持不变。智能体打开入口,由用户选择具体会话。", + "reasonEn": "“Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches” requires the user to enable discovery or select a removed source in ecosystem compatibility. Opening a source refreshes its history and first send creates an independent branch. Archives, source removal and branch deletion stay independent; original history is preserved. The Agent opens the entry for the user to choose a session.", + "presentationTarget": { + "kind": "action", + "actionId": "surface.ecosystemCompatibility.open" + }, + "evidence": [ + "command:list_removed_external_sessions", + "command:restore_removed_external_session", + "source:src/web-ui/src/app/scenes/ecosystem-compatibility/RemovedExternalSessions.tsx#RemovedExternalSessions", + "source:src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentSessions.tsx#ExternalAgentSessions" + ] + }, + { + "capabilityId": "feature.ecosystem-compatibility", + "itemId": "zcode-session-import", + "titleZh": "发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊", + "titleEn": "Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊”由用户在生态兼容页开启发现或选择已移除的来源。打开来源时刷新历史,首次发送创建独立分支。归档、移除和分支删除各自独立,原应用历史保持不变。智能体打开入口,由用户选择具体会话。", + "reasonEn": "“Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches” requires the user to enable discovery or select a removed source in ecosystem compatibility. Opening a source refreshes its history and first send creates an independent branch. Archives, source removal and branch deletion stay independent; original history is preserved. The Agent opens the entry for the user to choose a session.", + "presentationTarget": { + "kind": "action", + "actionId": "surface.ecosystemCompatibility.open" + }, + "evidence": [ + "command:list_removed_external_sessions", + "command:restore_removed_external_session", + "source:src/web-ui/src/app/scenes/ecosystem-compatibility/RemovedExternalSessions.tsx#RemovedExternalSessions", + "source:src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentSessions.tsx#ExternalAgentSessions" + ] + }, { "capabilityId": "feature.ecosystem-compatibility", "itemId": "prompts-commands", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 99a8e743af..5a2b90be08 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,11 +1,11 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "a0070347f9527a6eddeb84b5cd0a93d345c378aa008ce857976f02a9f4bd3d2f", - "commandCount": 674, + "catalogDigest": "1cd850650aa8b8df165b6fe69c90f060c240169f4becf163ecaba3fbe037000b", + "commandCount": 676, "coverage": { - "commandCount": 674, - "documentedCommandCount": 625, + "commandCount": 676, + "documentedCommandCount": 627, "implementationCommandCount": 49, "implementationDigest": "bc5063388e1b0dc13add3e967dd1b9c8638ee5e17d05ebcc680b419830c859ff" }, @@ -5325,6 +5325,23 @@ "signature": "fn list_project_permission_grants( state: State<'_, AppState>, runtime: State<'_, DesktopRuntimeContext>, request: PermissionProjectRequest, ) -> Result, String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "list_removed_external_sessions", + "moduleId": "external_session_import", + "capabilityId": "feature.ecosystem-compatibility", + "capabilityIds": [ + "feature.ecosystem-compatibility" + ], + "documentedItemIds": [ + "feature.ecosystem-compatibility:codex-session-import", + "feature.ecosystem-compatibility:zcode-session-import" + ], + "visibility": "documented", + "rustPath": "list_removed_external_sessions", + "sourceFile": "src/apps/desktop/src/api/external_session_import_api.rs", + "signature": "fn list_removed_external_sessions( request: ListRemovedExternalSessionsRequest, ) -> ExternalSourceOperationResult>", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, { "id": "list_sessions", "moduleId": "agentic", @@ -7901,6 +7918,23 @@ "signature": "fn restart_mcp_server( state: State<'_, AppState>, server_id: String, ) -> Result<(), String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "restore_removed_external_session", + "moduleId": "external_session_import", + "capabilityId": "feature.ecosystem-compatibility", + "capabilityIds": [ + "feature.ecosystem-compatibility" + ], + "documentedItemIds": [ + "feature.ecosystem-compatibility:codex-session-import", + "feature.ecosystem-compatibility:zcode-session-import" + ], + "visibility": "documented", + "rustPath": "restore_removed_external_session", + "sourceFile": "src/apps/desktop/src/api/external_session_import_api.rs", + "signature": "fn restore_removed_external_session( request: RestoreRemovedExternalSessionRequest, ) -> ExternalSourceOperationResult<()>", + "remoteWorkspacePolicy": "RemoteUnsupported" + }, { "id": "restore_session", "moduleId": "agentic", diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index ea5048333c..4c6452f845 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -187,6 +187,30 @@ test('Pi Skill discovery may use bounded static-source support without opening o } }); +test('Codex session history imports stay behind reviewed composition roots', async () => { + const rule = forbiddenContentUnderRules.find( + (rule) => rule.reason === 'Codex declarative source adapter imports are limited to its fixtures and composition roots', + ); + assert.ok(rule); + const sourcePath = 'src/crates/assembly/core/src/external_session_import.rs'; + const source = await readFile(new URL(`../${sourcePath}`, import.meta.url), 'utf8'); + assert.match(source, /use openbitfun_codex_adapter::session/); + assert.deepEqual(findForbiddenContentMatches(source, rule.patterns, sourcePath), []); + for (const rejectedPath of [ + 'src/crates/assembly/core/src/agentic/persistence/manager.rs', + 'src/apps/desktop/src/api/session.rs', + ]) { + assert.ok(findForbiddenContentMatches(source, rule.patterns, rejectedPath).length > 0, + `Codex transcript translation must remain behind the import facade at ${rejectedPath}`); + } + const publicApi = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/adapters/codex-adapter/src/lib.rs', + ); + const session = publicApi?.allowedSymbolEntries.find((entry) => entry.symbol === 'session'); + assert.equal(session?.contractSlice, 'external-session-import-contract'); + assert.match(session?.consumer ?? '', /external_session_import/); +}); + test('Cargo manifest discovery ignores nested local-agent worktrees', async (t) => { const root = await mkdtemp(join(tmpdir(), 'openbitfun-core-boundaries-')); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index cdd67da6ad..6e575655aa 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -40,6 +40,7 @@ export const crateLayoutRules = [ { crateName: 'ai-adapters', layer: 'adapters', path: 'src/crates/adapters/ai-adapters' }, { crateName: 'claude-code-adapter', layer: 'adapters', path: 'src/crates/adapters/claude-code-adapter' }, { crateName: 'codex-adapter', layer: 'adapters', path: 'src/crates/adapters/codex-adapter' }, + { crateName: 'zcode-adapter', layer: 'adapters', path: 'src/crates/adapters/zcode-adapter' }, { crateName: 'opencode-adapter', layer: 'adapters', path: 'src/crates/adapters/opencode-adapter' }, { crateName: 'dsh-adapter', layer: 'adapters', path: 'src/crates/adapters/dsh-adapter' }, { crateName: 'pi-adapter', layer: 'adapters', path: 'src/crates/adapters/pi-adapter' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 532fe6e076..4878894087 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -9,6 +9,7 @@ const agentRuntimeIpcForbiddenDeps = [ 'openbitfun-ai-adapters', 'openbitfun-claude-code-adapter', 'openbitfun-codex-adapter', + 'openbitfun-zcode-adapter', 'openbitfun-core', 'openbitfun-core-types', 'openbitfun-dsh-adapter', @@ -60,6 +61,7 @@ export const noCoreDependencyCrates = [ 'product-domains', 'claude-code-adapter', 'codex-adapter', + 'zcode-adapter', 'dsh-adapter', 'opencode-adapter', 'opencode-plugin-host', @@ -146,6 +148,7 @@ export const forbiddenManifestDependencyRules = [ ...[ ['openbitfun-claude-code-adapter', 'claude-code-adapter'], ['openbitfun-codex-adapter', 'codex-adapter'], + ['openbitfun-zcode-adapter', 'zcode-adapter'], ['openbitfun-pi-adapter', 'pi-adapter'], ].map(([dependencyName, crateName]) => ({ dependencyNames: [dependencyName], @@ -156,7 +159,7 @@ export const forbiddenManifestDependencyRules = [ 'src/crates/assembly/core/Cargo.toml', ], reason: `${crateName} production dependencies are limited to the reviewed product composition root`, - message: `only openbitfun-core product-full assembly may register ${dependencyName} through the static Hook provider boundary`, + message: `only openbitfun-core external-sources assembly may register ${dependencyName} through its reviewed source capability boundary`, })), { dependencyNames: ['openbitfun-static-hook-support'], diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index fa4191fe8b..cb834d2f3a 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -172,6 +172,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'openbitfun-agent-tools', ownerFeatures: ['agent-runtime', 'local-storage', 'mcp-runtime'] }, { depName: 'openbitfun-claude-code-adapter', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-codex-adapter', ownerFeatures: ['external-sources'] }, + { depName: 'openbitfun-zcode-adapter', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-external-sources', ownerFeatures: ['external-sources'] }, { depName: 'openbitfun-legacy-migration-adapters', ownerFeatures: ['legacy-migration'] }, { depName: 'openbitfun-opencode-adapter', ownerFeatures: ['external-sources'] }, @@ -996,6 +997,7 @@ export const coreClosedFeatureProfileRules = [ 'dep:openbitfun-pi-adapter', 'dep:openbitfun-claude-code-adapter', 'dep:openbitfun-codex-adapter', + 'dep:openbitfun-zcode-adapter', 'dep:openbitfun-external-sources', 'openbitfun-services-integrations/hook-import', 'plugin-source', diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index ef4e638b71..2b06533e0a 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4218,6 +4218,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', + 'src/crates/assembly/core/src/external_session_import.rs', 'src/crates/assembly/core/src/instruction_sources.rs', ], message: 'Codex declarative source adapter may only be imported by its fixtures and reviewed composition roots', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 158d600827..aac3f6a11f 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -13,6 +13,7 @@ export const publicApiContractSlices = [ 'external-source-pet-contract', 'external-source-hook-contract', 'external-source-reference-contract', + 'external-session-import-contract', 'user-instruction-source-boundary', 'external-integration-policy-contract', ]; @@ -72,6 +73,7 @@ const contractSlices = { externalSourcePetContract: 'external-source-pet-contract', externalSourceHookContract: 'external-source-hook-contract', externalSourceReferenceContract: 'external-source-reference-contract', + externalSessionImportContract: 'external-session-import-contract', userInstructionSourceBoundary: 'user-instruction-source-boundary', externalIntegrationPolicyContract: 'external-integration-policy-contract', }; @@ -552,7 +554,17 @@ export const codexAdapterPublicApiEntries = [ wireImpact: false, rationale: 'the adapter translates Codex package metadata; portable services own bounded resource IO and native copying', exit: 'remove only when an equivalent typed pet source provider owns this root', -}))); +}))).concat([{ + symbol: 'session', + owner: 'Codex adapter read-only session history translator', + consumer: 'openbitfun-core external_session_import composition facade', + verification: 'Codex session parser fixtures and core external_session_import tests', + p0: 'read-only Codex history import with native continuation and preserved provenance', + contractSlice: contractSlices.externalSessionImportContract, + wireImpact: false, + rationale: 'the adapter translates Codex transcript records; core owns native session persistence and lifecycle', + exit: 'remove only when an equivalent typed session history adapter replaces this module', +}]); export const staticHookSupportPublicApiEntries = [ // Shared by DSH/PI only for validated, redacted source/event construction. diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index e3a2a6ea51..de3a021f26 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1982,6 +1982,7 @@ export function runManifestParserSelfTest({ for (const dependencyName of [ 'openbitfun-claude-code-adapter', 'openbitfun-codex-adapter', + 'openbitfun-zcode-adapter', 'openbitfun-static-hook-support', ]) { if (!forbiddenManifestDependencyRules.some( diff --git a/src/apps/desktop/src/api/external_session_import_api.rs b/src/apps/desktop/src/api/external_session_import_api.rs new file mode 100644 index 0000000000..33f8157448 --- /dev/null +++ b/src/apps/desktop/src/api/external_session_import_api.rs @@ -0,0 +1,29 @@ +use openbitfun_core::external_session_import::{ + list_removed_external_sessions as list_removed, + restore_removed_external_session as restore_removed, +}; +use openbitfun_product_domains::external_sources::ExternalSourceOperationResult; +use openbitfun_product_domains::session_import::{ + RemovedExternalSession, RestoreRemovedExternalSessionRequest, +}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListRemovedExternalSessionsRequest { + pub ecosystem_id: String, +} + +#[tauri::command] +pub async fn list_removed_external_sessions( + request: ListRemovedExternalSessionsRequest, +) -> ExternalSourceOperationResult> { + list_removed(&request.ecosystem_id).await +} + +#[tauri::command] +pub async fn restore_removed_external_session( + request: RestoreRemovedExternalSessionRequest, +) -> ExternalSourceOperationResult<()> { + restore_removed(request).await +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 5b15d7fb0d..c3d9bd2cb7 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -25,6 +25,7 @@ pub mod dto; pub mod editor_ai_api; pub mod event_coalescer; pub mod external_hooks_api; +pub mod external_session_import_api; pub mod external_sources_api; pub mod file_drop_preview_api; pub mod git_agent_api; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index c8cd9f7876..c10eeee005 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -80,6 +80,7 @@ use api::custom_agent_api::{ }; use api::diff_api::*; use api::external_hooks_api::*; +use api::external_session_import_api::*; use api::external_sources_api::*; use api::git_agent_api::*; use api::git_api::*; @@ -1342,6 +1343,8 @@ pub async fn run() { reveal_external_source_location, get_external_source_control_snapshot, get_external_source_discovery_snapshot, + list_removed_external_sessions, + restore_removed_external_session, apply_external_source_control_action_command, get_external_ecosystem_awareness_command, acknowledge_external_ecosystems_command, diff --git a/src/crates/adapters/AGENTS.md b/src/crates/adapters/AGENTS.md index f26666841a..df4b715f08 100644 --- a/src/crates/adapters/AGENTS.md +++ b/src/crates/adapters/AGENTS.md @@ -18,6 +18,7 @@ services. | `pi-adapter` | PI settings/package extension selection and static native event discovery; no execution | [AGENTS.md](pi-adapter/AGENTS.md) | | `claude-code-adapter` | Runtime-free Claude Code user Instructions, Command, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](claude-code-adapter/AGENTS.md) | | `codex-adapter` | Runtime-free Codex user Instructions, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](codex-adapter/AGENTS.md) | +| `zcode-adapter` | Read-only Zcode SQLite task discovery and session-history translation | [AGENTS.md](zcode-adapter/AGENTS.md) | | `static-hook-support` | Shared bounded/redacting static-source utilities plus the JSON/TOML Hook parser used by sibling ecosystem adapters; no ecosystem policy or runtime | inherited | | `transport` | Event transport adapters plus protocol-neutral bounded JSON encoding and TypeScript message/JSON-RPC mechanics shared by current hosts | [AGENTS.md](transport/AGENTS.md) | | `webdriver` | Embedded WebDriver protocol and browser automation adapter | [AGENTS.md](webdriver/AGENTS.md) | diff --git a/src/crates/adapters/codex-adapter/AGENTS.md b/src/crates/adapters/codex-adapter/AGENTS.md index 24e2623c86..91dd477639 100644 --- a/src/crates/adapters/codex-adapter/AGENTS.md +++ b/src/crates/adapters/codex-adapter/AGENTS.md @@ -6,6 +6,16 @@ - Preserve Codex configuration-layer and field-overlay semantics inside each typed provider. Unknown behavior fields must block explicitly; do not invent Codex Command or standalone Tool sources. - Do not depend on another ecosystem adapter. Shared product behavior belongs in contracts or assembly. +## Session discovery and import verification + +```bash +cargo test -p openbitfun-codex-adapter --lib session::tests +# Optional read-only installed-source verification (prints counts only): +cargo test -p openbitfun-codex-adapter --lib inspect_installed_codex_session_titles -- --ignored --nocapture +# Set OPENBITFUN_TEST_CODEX_THREAD to inspect one logical thread's full history: +cargo test -p openbitfun-codex-adapter --lib inspect_installed_codex_thread_history -- --ignored --nocapture +``` + ## Pet source verification ```bash diff --git a/src/crates/adapters/codex-adapter/Cargo.toml b/src/crates/adapters/codex-adapter/Cargo.toml index 7db933a3fe..b9f93d9d0d 100644 --- a/src/crates/adapters/codex-adapter/Cargo.toml +++ b/src/crates/adapters/codex-adapter/Cargo.toml @@ -17,8 +17,10 @@ openbitfun-static-hook-support = { path = "../static-hook-support" } dirs = { workspace = true } dunce = { workspace = true } hex = { workspace = true } +rusqlite = { workspace = true, features = ["bundled"] } sha2 = { workspace = true } serde_json = { workspace = true } +chrono = { workspace = true } toml = { workspace = true } url = { workspace = true } diff --git a/src/crates/adapters/codex-adapter/src/lib.rs b/src/crates/adapters/codex-adapter/src/lib.rs index 3ee8153781..f4d6cedc43 100644 --- a/src/crates/adapters/codex-adapter/src/lib.rs +++ b/src/crates/adapters/codex-adapter/src/lib.rs @@ -5,6 +5,7 @@ mod hook_source; mod instruction_source; mod mcp_source; mod pet_source; +pub mod session; pub use pet_source::{builtin_pet_sources, BuiltinPetCatalog, BuiltinPetSource}; diff --git a/src/crates/adapters/codex-adapter/src/session.rs b/src/crates/adapters/codex-adapter/src/session.rs new file mode 100644 index 0000000000..b98586afa3 --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/session.rs @@ -0,0 +1,1347 @@ +//! Bounded, read-only Codex rollout import. Discovery reads headers, never full histories. +use openbitfun_product_domains::session_import::*; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +mod titles; +use titles::{TitleCache, TitleSnapshot}; + +pub const SOURCE_REVISION: &str = "codex-rollout-jsonl-v2"; +const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024; +const MAX_LINE_BYTES: usize = 8 * 1024 * 1024; +const MAX_DOCUMENT_BYTES: usize = 128 * 1024 * 1024; +const HEADER_BYTES: usize = 256 * 1024; +const MAX_FILES: usize = 100_000; +const MAX_TURNS: usize = 4096; + +#[derive(Clone)] +pub struct CodexSessionReader { + pub codex_home: PathBuf, + headers: Arc>>, + titles: Arc>, + #[cfg(test)] + header_reads: Arc, +} + +#[derive(Clone)] +struct CachedHeader { + modified: Option, + size: u64, + candidate: ExternalSessionCandidate, +} +impl Default for CodexSessionReader { + fn default() -> Self { + Self::new( + std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".codex"))) + .unwrap_or_default(), + ) + } +} +impl CodexSessionReader { + pub fn new(codex_home: PathBuf) -> Self { + Self { + codex_home, + headers: Arc::default(), + titles: Arc::default(), + #[cfg(test)] + header_reads: Arc::default(), + } + } + pub fn source_revision(&self) -> &'static str { + SOURCE_REVISION + } + + fn title_snapshot(&self) -> Arc { + self.titles + .lock() + .unwrap_or_else(|error| error.into_inner()) + .snapshot(&self.codex_home) + } + fn paths(&self) -> Result, String> { + if !self.codex_home.is_absolute() { + return Err("Codex home must be an absolute host path".into()); + } + let mut paths = Vec::new(); + let mut visits = 0usize; + for name in ["sessions", "archived_sessions"] { + let root = self.codex_home.join(name); + if root.exists() { + collect(&root, &mut paths, 0, &mut visits)?; + } + } + let present = paths.iter().collect::>(); + self.headers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .retain(|path, _| present.contains(path)); + Ok(paths) + } + pub fn discover( + &self, + cursor: usize, + limit: usize, + query: &str, + ) -> Result { + self.discover_page(cursor, limit.clamp(1, 50), query) + } + + /// Host-owned automatic discovery traverses the bounded source index once, + /// rather than rescanning every rollout header for each UI page. + pub fn discover_all(&self) -> Result { + self.discover_page(0, usize::MAX, "") + } + + fn discover_page( + &self, + cursor: usize, + limit: usize, + query: &str, + ) -> Result { + let titles = self.title_snapshot(); + let mut diagnostics = titles.diagnostics.clone(); + let paths = self.logical_session_paths(&titles, &mut diagnostics)?; + let mut candidates = Vec::new(); + let mut matched = 0usize; + let mut has_more = false; + let query = query.to_lowercase(); + for path in paths { + let candidate = match self.inspect_path_with_titles(&path, &titles) { + Ok(candidate) => candidate, + Err(error) => { + if diagnostics.len() < 20 { + diagnostics.push(warning("source_unreadable", &error)); + } + continue; + } + }; + if !query.is_empty() + && !format!( + "{} {}", + candidate.title.as_deref().unwrap_or(""), + candidate.cwd.as_deref().unwrap_or("") + ) + .to_lowercase() + .contains(&query) + { + continue; + } + matched += 1; + if matched <= cursor { + continue; + } + if candidates.len() == limit { + has_more = true; + break; + } + candidates.push(candidate); + } + Ok(ExternalSessionCatalog { + schema_version: 1, + ecosystem_id: "codex".into(), + source_revision: SOURCE_REVISION.into(), + next_cursor: has_more.then_some(cursor + candidates.len()), + candidates, + diagnostics, + }) + } + + pub fn read_candidate( + &self, + id: &str, + mode: ExternalSessionImportMode, + ) -> Result { + let cached = self + .headers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .find(|(_, header)| header.candidate.candidate_id == id) + .map(|(path, _)| path.clone()); + if let Some(path) = + cached.filter(|path| path.starts_with(&self.codex_home) && path.is_file()) + { + return self.read_current_rollout(&path, mode); + } + let path = self + .paths()? + .into_iter() + .find(|path| candidate_id(path) == id) + .ok_or("Codex session was removed or moved; refresh the list")?; + // Populate the bounded header cache so a legacy locator can resolve to + // the current rollout of the same logical Codex thread. + let titles = self.title_snapshot(); + self.logical_session_paths(&titles, &mut Vec::new())?; + self.read_current_rollout(&path, mode) + } + + /// Codex can leave several rollout files for one thread after a resume or + /// retry. Prefer its current state-database locator; timestamps are only a + /// fallback for installations without that optional column/database. + fn logical_session_paths( + &self, + titles: &TitleSnapshot, + diagnostics: &mut Vec, + ) -> Result, String> { + let mut selected: HashMap = HashMap::new(); + for path in self.paths()? { + let candidate = match self.inspect_header(&path) { + Ok(candidate) => candidate, + Err(error) => { + if diagnostics.len() < 20 { + diagnostics.push(warning("source_unreadable", &error)); + } + continue; + } + }; + let rank = |path: &Path, candidate: &ExternalSessionCandidate| { + ( + titles.is_current_rollout(&candidate.source_session_id, path), + candidate.created_at, + candidate.updated_at, + path.to_path_buf(), + ) + }; + let replace = selected.get(&candidate.source_session_id).is_none_or( + |(previous_path, previous)| rank(&path, &candidate) > rank(previous_path, previous), + ); + if replace { + selected.insert(candidate.source_session_id.clone(), (path, candidate)); + } + } + let mut selected = selected.into_values().collect::>(); + selected.sort_by(|(left_path, left), (right_path, right)| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left_path.cmp(right_path)) + }); + Ok(selected.into_iter().map(|(path, _)| path).collect()) + } + + fn read_current_rollout( + &self, + path: &Path, + mode: ExternalSessionImportMode, + ) -> Result { + let candidate = self.inspect_header(path)?; + let titles = self.title_snapshot(); + // A resume can publish a new rollout after the last catalog scan. Read + // only this thread's indexed file, after checking the source boundary. + if let Some(current) = titles.current_rollout(&candidate.source_session_id) { + let current = PathBuf::from(current.strip_prefix("\\\\?\\").unwrap_or(current)); + if let (Ok(resolved), Ok(home)) = ( + fs::canonicalize(¤t), + fs::canonicalize(&self.codex_home), + ) { + if ["sessions", "archived_sessions"] + .iter() + .any(|name| resolved.starts_with(home.join(name))) + && self + .inspect_header(¤t) + .is_ok_and(|header| header.source_session_id == candidate.source_session_id) + { + return self.read_path(¤t, mode); + } + } + } + let selected = self + .headers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .iter() + .filter(|(path, header)| { + header.candidate.source_session_id == candidate.source_session_id && path.is_file() + }) + .max_by_key(|(path, header)| { + ( + titles.is_current_rollout(&candidate.source_session_id, path), + header.candidate.created_at, + header.candidate.updated_at, + (*path).clone(), + ) + }) + .map(|(path, _)| path.clone()) + .unwrap_or_else(|| path.to_path_buf()); + self.read_path(&selected, mode) + } + pub fn inspect_path(&self, path: &Path) -> Result { + self.inspect_path_with_titles(path, &self.title_snapshot()) + } + + fn inspect_path_with_titles( + &self, + path: &Path, + titles: &TitleSnapshot, + ) -> Result { + let mut candidate = self.inspect_header(path)?; + titles.apply(&mut candidate); + Ok(candidate) + } + + fn inspect_header(&self, path: &Path) -> Result { + let meta = fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !meta.is_file() { + return Err("Codex rollout is not a regular file".into()); + } + if let Some(cached) = self + .headers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(path) + { + if cached.modified == meta.modified().ok() && cached.size == meta.len() { + return Ok(cached.candidate.clone()); + } + } + #[cfg(test)] + self.header_reads + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut reader = BufReader::new(File::open(path).map_err(|error| error.to_string())?) + .take(HEADER_BYTES as u64); + let mut line = Vec::new(); + let mut session = None; + let mut title = None; + let mut fallback_title = None; + let mut model = None; + loop { + line.clear(); + if reader + .read_until(b'\n', &mut line) + .map_err(|error| error.to_string())? + == 0 + { + break; + } + let Ok(value) = serde_json::from_slice::(&line) else { + continue; + }; + match value["type"].as_str().unwrap_or("") { + "session_meta" => { + session = Some( + value["payload"] + .get("meta") + .unwrap_or(&value["payload"]) + .clone(), + ) + } + "turn_context" => model = value["payload"]["model"].as_str().map(str::to_owned), + "response_item" => { + let p = value["payload"].get("item").unwrap_or(&value["payload"]); + if p["type"] == "message" && p["role"] == "user" && title.is_none() { + let text = content_text(&p["content"]); + if !text.is_empty() && !is_injected(&text) { + fallback_title.get_or_insert_with(|| preview(&text, 120)); + title = user_title_preview(&text); + } + } + } + _ => {} + } + if session.is_some() && title.is_some() { + break; + } + } + let session = session.ok_or("Rollout header not found in the bounded prefix")?; + let source_id = session["id"] + .as_str() + .or_else(|| session["session_id"].as_str()) + .ok_or("Codex session id is missing")? + .to_owned(); + let updated = meta + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64); + let candidate = ExternalSessionCandidate { + candidate_id: candidate_id(path), + ecosystem_id: "codex".into(), + source_session_id: source_id, + title: session["name"] + .as_str() + .or_else(|| session["title"].as_str()) + .filter(|title| !title.trim().is_empty()) + .map(str::to_owned) + .or(title), + fallback_title, + title_is_source_name: session["name"] + .as_str() + .or_else(|| session["title"].as_str()) + .is_some_and(|title| !title.trim().is_empty()), + cwd: session["cwd"].as_str().map(str::to_owned), + created_at: timestamp(&session["timestamp"]), + updated_at: updated, + model_provider: session["model_provider"].as_str().map(str::to_owned), + model_name: model, + turn_count: None, + file_size_bytes: meta.len(), + format_version: session["cli_version"].as_str().map(str::to_owned), + status: if meta.len() > MAX_FILE_BYTES { + ExternalSessionStatus::Unsupported + } else { + ExternalSessionStatus::Complete + }, + fingerprint: format!("metadata:{}:{}", meta.len(), updated.unwrap_or(0)), + source_location: path + .strip_prefix(&self.codex_home) + .ok() + .map(|p| format!("$CODEX_HOME/{}", p.to_string_lossy().replace('\\', "/"))), + target_workspace_id: None, + target_workspace_name: None, + imported_session_id: None, + }; + self.headers + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert( + path.to_path_buf(), + CachedHeader { + modified: meta.modified().ok(), + size: meta.len(), + candidate: candidate.clone(), + }, + ); + Ok(candidate) + } + fn read_path( + &self, + path: &Path, + mode: ExternalSessionImportMode, + ) -> Result { + let titles = self.title_snapshot(); + let mut candidate = self.inspect_path_with_titles(path, &titles)?; + if candidate.file_size_bytes > MAX_FILE_BYTES { + return Err("Source exceeds the 256 MiB import limit".into()); + } + let mut reader = BufReader::new(File::open(path).map_err(|e| e.to_string())?); + let mut hasher = Sha256::new(); + let mut state = Materializer::default(); + let mut line = Vec::new(); + let mut warnings = BTreeSet::new(); + let mut total = 0usize; + let mut compacted = false; + loop { + line.clear(); + let read = reader + .by_ref() + .take((MAX_LINE_BYTES + 1) as u64) + .read_until(b'\n', &mut line) + .map_err(|e| e.to_string())?; + if read == 0 { + break; + } + if read > MAX_LINE_BYTES { + return Err("JSONL line exceeds the 8 MiB import limit".into()); + } + total += read; + if total as u64 > MAX_FILE_BYTES { + return Err("Source grew beyond the import limit".into()); + } + hasher.update(&line); + if line.iter().all(u8::is_ascii_whitespace) { + continue; + } + let record: Value = match serde_json::from_slice(&line) { + Ok(record) => record, + Err(_) + if !line.ends_with(b"\n") + && reader.fill_buf().map_err(|e| e.to_string())?.is_empty() => + { + warnings.insert("torn_tail"); + state.interrupt(); + break; + } + Err(_) => { + return Err( + "Malformed JSONL inside the rollout; no partial import was created".into(), + ) + } + }; + let time = timestamp(&record["timestamp"]) + .or(candidate.created_at) + .unwrap_or(0); + match record["type"].as_str().unwrap_or("") { + "response_item" => state.item( + record["payload"].get("item").unwrap_or(&record["payload"]), + time, + mode, + &mut warnings, + )?, + "turn_context" => { + if let Some(model) = record["payload"]["model"].as_str() { + candidate.model_name = Some(model.into()); + } + } + "event_msg" => state.event(&record["payload"], &mut warnings)?, + "compacted" => { + compacted = true; + warnings.insert("compaction_present"); + if mode == ExternalSessionImportMode::LatestCompacted { + let payload = &record["payload"]; + if let Some(items) = payload["replacement_history"].as_array() { + state = Materializer::default(); + for item in items { + state.item(item, time, mode, &mut warnings)?; + } + } else if let Some(summary) = + payload["message"].as_str().filter(|s| !s.is_empty()) + { + state = Materializer::default(); + state.item( + &json!({"type":"message","role":"user","content":format!("[Imported Codex compaction summary]\n{summary}")}), + time, + mode, + &mut warnings, + )?; + warnings.insert("summary_only"); + } else { + return Err( + "Codex compaction has no readable replacement history or summary" + .into(), + ); + } + } + } + "session_meta" | "token_usage_record" | "retained_context" => {} + _ => { + warnings.insert("unknown_records"); + } + } + } + if mode == ExternalSessionImportMode::LatestCompacted && !compacted { + warnings.insert("no_compaction"); + } + state.finish(); + if state.turns.is_empty() { + return Err("No usable human conversation was found".into()); + } + if state.turns.len() > MAX_TURNS { + return Err("Conversation exceeds the 4096 turn import limit".into()); + } + let text_bytes: usize = state + .turns + .iter() + .map(|t| { + t.user_text.len() + + t.items + .iter() + .map(|i| serde_json::to_vec(i).map_or(0, |v| v.len())) + .sum::() + }) + .sum(); + if text_bytes > MAX_DOCUMENT_BYTES { + return Err("Converted history exceeds the 128 MiB storage budget".into()); + } + candidate.fingerprint = format!("sha256:{}", hex::encode(hasher.finalize())); + candidate.turn_count = Some(state.turns.len()); + candidate.status = state.turns.last().unwrap().status; + Ok(CodexSessionDocument { + candidate, + turns: state.turns, + warnings: warnings + .into_iter() + .map(|code| warning(code, code)) + .chain(titles.diagnostics.iter().cloned()) + .collect(), + }) + } +} + +#[derive(Default)] +struct Materializer { + turns: Vec, + seen: HashSet, +} +impl Materializer { + fn item( + &mut self, + item: &Value, + time: u64, + mode: ExternalSessionImportMode, + warnings: &mut BTreeSet<&'static str>, + ) -> Result<(), String> { + if let Some(id) = item["id"].as_str() { + if !self.seen.insert(id.into()) { + return Ok(()); + } + } + match item["type"].as_str().unwrap_or("") { + "message" | "agent_message" => { + let text = content_text(&item["content"]); + if item["content"].as_array().is_some_and(|items| { + items + .iter() + .any(|i| matches!(i["type"].as_str(), Some("input_image" | "image"))) + }) { + warnings.insert("images_not_imported"); + } + if text.is_empty() { + return Ok(()); + } + match item["role"].as_str().unwrap_or("assistant") { + "user" if !is_injected(&text) => { + self.finish(); + self.turns.push(ExternalSessionTurn { + source_turn_id: format!("source-turn-{}", self.turns.len()), + timestamp: time, + user_text: text, + items: Vec::new(), + source_event_count: 1, + status: ExternalSessionStatus::Interrupted, + }); + } + "assistant" => { + if let Some(turn) = self.turns.last_mut() { + turn.items.push(ExternalSessionItem::Text { text }); + turn.source_event_count += 1; + } + } + _ => { + warnings.insert("source_instructions_not_imported"); + } + } + } + "reasoning" => { + let text = content_text(&item["summary"]); + if let Some(turn) = self.turns.last_mut() { + if !text.is_empty() { + turn.items.push(ExternalSessionItem::Thinking { text }); + } + } + } + "function_call" | "custom_tool_call" => { + if let Some(turn) = self.turns.last_mut() { + let id = item["call_id"] + .as_str() + .or_else(|| item["id"].as_str()) + .ok_or("Tool call id is missing")? + .to_owned(); + let name = item["name"].as_str().unwrap_or("unknown").to_owned(); + let raw = item + .get("arguments") + .or_else(|| item.get("input")) + .cloned() + .unwrap_or(Value::Null); + let input = raw + .as_str() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(raw); + turn.items.push(ExternalSessionItem::Tool { + id, + name, + input, + result: None, + success: None, + }); + turn.source_event_count += 1; + } + } + "function_call_output" | "custom_tool_call_output" => { + let id = item["call_id"].as_str().unwrap_or(""); + let mut matched = false; + for turn in self.turns.iter_mut().rev() { + if let Some(ExternalSessionItem::Tool { + result, success, .. + }) = turn.items.iter_mut().rev().find( + |i| matches!(i, ExternalSessionItem::Tool { id: call, .. } if call == id), + ) { + *result = Some(item.get("output").cloned().unwrap_or(Value::Null)); + *success = Some(item["is_error"] != true); + matched = true; + break; + } + } + if !matched { + warnings.insert("orphan_tool_result"); + } + } + "compaction" | "context_compaction" => { + warnings.insert("compaction_present"); + if mode == ExternalSessionImportMode::LatestCompacted { + return Err( + "Codex compacted history contains an encrypted context summary; select full history to preserve readable conversation" + .into(), + ); + } + } + _ => { + warnings.insert("unsupported_response_item"); + } + } + Ok(()) + } + fn event( + &mut self, + event: &Value, + warnings: &mut BTreeSet<&'static str>, + ) -> Result<(), String> { + match event["type"].as_str().unwrap_or("") { + "task_complete" | "turn_complete" => { + if let Some(t) = self.turns.last_mut() { + t.status = ExternalSessionStatus::Complete; + } + } + "turn_aborted" | "error" => self.interrupt(), + "thread_rolled_back" => { + let n = event["num_turns"] + .as_u64() + .ok_or("Invalid rollback count")? as usize; + self.turns.truncate(self.turns.len().saturating_sub(n)); + self.seen.clear(); + warnings.insert("rolled_back_history"); + } + // Event mirrors never become additional human prompts or assistant output. + "user_message" | "agent_message" | "task_started" | "turn_started" | "token_count" + | "agent_reasoning" | "item_completed" => {} + _ => { + warnings.insert("unknown_events"); + } + } + Ok(()) + } + fn interrupt(&mut self) { + if let Some(t) = self.turns.last_mut() { + t.status = ExternalSessionStatus::Interrupted; + } + } + fn finish(&mut self) { + if let Some(t) = self.turns.last_mut() { + if t.status == ExternalSessionStatus::Interrupted + && t.items + .iter() + .any(|i| matches!(i, ExternalSessionItem::Text { .. })) + && !t + .items + .iter() + .any(|i| matches!(i, ExternalSessionItem::Tool { result: None, .. })) + { + t.status = ExternalSessionStatus::Complete; + } + } + } +} +fn collect( + root: &Path, + paths: &mut Vec, + depth: usize, + visits: &mut usize, +) -> Result<(), String> { + if depth > 8 { + return Err("Codex session directory exceeds the discovery depth limit".into()); + } + for entry in fs::read_dir(root).map_err(|e| e.to_string())? { + *visits += 1; + if *visits > MAX_FILES { + return Err("Codex discovery exceeds 100000 directory entries".into()); + } + let entry = entry.map_err(|e| e.to_string())?; + let kind = entry.file_type().map_err(|e| e.to_string())?; + if kind.is_symlink() { + continue; + } + if kind.is_dir() { + collect(&entry.path(), paths, depth + 1, visits)?; + } else if kind.is_file() + && entry.file_name().to_string_lossy().starts_with("rollout-") + && entry.path().extension().is_some_and(|e| e == "jsonl") + { + paths.push(entry.path()); + } + } + Ok(()) +} +fn candidate_id(path: &Path) -> String { + format!( + "codex:{}", + hex::encode(Sha256::digest(path.to_string_lossy().as_bytes())) + ) +} +fn timestamp(value: &Value) -> Option { + value.as_u64().or_else(|| { + value + .as_str() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .and_then(|d| u64::try_from(d.timestamp_millis()).ok()) + }) +} +fn content_text(v: &Value) -> String { + if let Some(text) = v.as_str() { + return text.into(); + } + v.as_array() + .map(|items| { + items + .iter() + .filter(|i| { + matches!( + i["type"].as_str(), + Some("input_text" | "output_text" | "text" | "summary_text") + ) + }) + .filter_map(|i| i["text"].as_str()) + .collect::>() + .join("\n") + }) + .unwrap_or_default() +} +fn is_injected(text: &str) -> bool { + let text = text.trim_start(); + text.starts_with("# AGENTS.md instructions for ") + || text.starts_with("") + || text.starts_with("") + || text.starts_with("") + || text.starts_with("") + && text + .trim_end() + .ends_with("")) +} +fn user_title_preview(text: &str) -> Option { + let mut text = text.trim(); + // These are Codex UI context envelopes, not the human's session name. + for (start, end) in [ + ("", ""), + (""), + ] { + if text.starts_with(start) { + text = text.split_once(end)?.1.trim(); + } + } + if text.starts_with("# Files mentioned by the user:") { + text = text.split_once("## My request:")?.1.trim(); + } + (!text.is_empty()).then(|| preview(text, 120)) +} + +pub fn preview(text: &str, chars: usize) -> String { + text.chars().take(chars).collect() +} +fn warning(code: &str, detail: &str) -> ExternalSessionImportWarning { + ExternalSessionImportWarning { + code: code.into(), + detail: detail.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resumed_rollout(root: &Path, name: &str, timestamp: &str, answer: Option<&str>) -> PathBuf { + let path = root.join("sessions").join(format!("rollout-{name}.jsonl")); + let mut rows = vec![ + json!({"type":"session_meta","timestamp":timestamp,"payload":{"id":"one","cwd":"/project","timestamp":timestamp}}), + msg("user", "Original question"), + ]; + if let Some(answer) = answer { + rows.push(msg("assistant", answer)); + } + fs::write( + &path, + rows.iter() + .map(Value::to_string) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); + path + } + + #[test] + fn resumed_threads_are_deduplicated_before_paging_and_legacy_locators_read_the_current_rollout() + { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("sessions")).unwrap(); + let old = resumed_rollout(dir.path(), "old", "2026-10-15T03:08:00Z", None); + let current = resumed_rollout( + dir.path(), + "current", + "2026-10-15T03:22:00Z", + Some("Complete answer"), + ); + let reader = CodexSessionReader::new(dir.path().into()); + let page = reader.discover(0, 1, "").unwrap(); + assert_eq!(page.candidates.len(), 1); + assert!(page.next_cursor.is_none()); + assert_eq!(page.candidates[0].candidate_id, candidate_id(¤t)); + for reader in [reader, CodexSessionReader::new(dir.path().into())] { + let document = reader + .read_candidate(&candidate_id(&old), ExternalSessionImportMode::Full) + .unwrap(); + assert_eq!(document.candidate.candidate_id, candidate_id(¤t)); + assert!( + matches!(&document.turns[0].items[0], ExternalSessionItem::Text { text } if text == "Complete answer") + ); + } + } + + #[test] + fn state_locator_wins_over_retry_timestamps_and_refreshes_after_discovery() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("sessions")).unwrap(); + let old = resumed_rollout(dir.path(), "old", "2026-10-15T03:22:00Z", None); + let reader = CodexSessionReader::new(dir.path().into()); + reader.discover_all().unwrap(); + let current = resumed_rollout( + dir.path(), + "current", + "2026-10-15T03:08:00Z", + Some("Indexed answer"), + ); + let connection = rusqlite::Connection::open(dir.path().join("state_5.sqlite")).unwrap(); + connection + .execute_batch("CREATE TABLE threads (id TEXT, title TEXT, rollout_path TEXT)") + .unwrap(); + connection + .execute( + "INSERT INTO threads VALUES ('one', 'Saved name', ?1)", + [current.to_string_lossy().as_ref()], + ) + .unwrap(); + let document = reader + .read_candidate(&candidate_id(&old), ExternalSessionImportMode::Full) + .unwrap(); + assert_eq!(document.candidate.candidate_id, candidate_id(¤t)); + assert_eq!( + reader.discover_all().unwrap().candidates[0].candidate_id, + candidate_id(¤t) + ); + } + + #[test] + #[ignore = "Read-only inspection of one installed Codex thread, selected by environment variable"] + fn inspect_installed_codex_thread_history() { + let id = std::env::var("OPENBITFUN_TEST_CODEX_THREAD") + .expect("Set OPENBITFUN_TEST_CODEX_THREAD"); + let reader = CodexSessionReader::default(); + let catalog = reader.discover_all().unwrap(); + let candidates = catalog + .candidates + .iter() + .filter(|candidate| candidate.source_session_id == id) + .collect::>(); + assert_eq!(candidates.len(), 1); + let document = reader + .read_candidate(&candidates[0].candidate_id, ExternalSessionImportMode::Full) + .unwrap(); + let text_count = document + .turns + .iter() + .flat_map(|turn| &turn.items) + .filter(|item| matches!(item, ExternalSessionItem::Text { .. })) + .count(); + let tool_count = document + .turns + .iter() + .flat_map(|turn| &turn.items) + .filter(|item| matches!(item, ExternalSessionItem::Tool { .. })) + .count(); + assert!(!document.turns.is_empty()); + assert!(text_count > 0); + println!("Codex history inspection: {} logical thread, {} turns, {} text items, {} tool items; warning codes: {:?}", candidates.len(), document.turns.len(), text_count, tool_count, document.warnings.iter().map(|warning| &warning.code).collect::>()); + } + + #[test] + fn persisted_names_override_prompt_previews_in_catalogs_and_imports() { + let dir = fixture(vec![ + json!({"type":"session_meta","payload":{"id":"one","cwd":"/project"}}), + msg("user", "Original question"), + msg("assistant", "Saved answer"), + ]); + let connection = rusqlite::Connection::open(dir.path().join("state_5.sqlite")).unwrap(); + connection.execute_batch("CREATE TABLE threads (id TEXT, title TEXT, name TEXT, future_column TEXT); + INSERT INTO threads VALUES ('one', 'Original question', 'User named conversation', 'ignored');").unwrap(); + fs::write( + dir.path().join("session_index.jsonl"), + "{\"id\":\"one\",\"thread_name\":\"Earlier indexed name\"}\n", + ) + .unwrap(); + let reader = CodexSessionReader::new(dir.path().into()); + let catalog = reader.discover(0, 10, "User named").unwrap(); + assert!(catalog.diagnostics.is_empty()); + assert_eq!(catalog.candidates.len(), 1); + let candidate = &catalog.candidates[0]; + assert_eq!(candidate.title.as_deref(), Some("User named conversation")); + assert_eq!( + candidate.fallback_title.as_deref(), + Some("Original question") + ); + assert!(candidate.title_is_source_name); + let document = reader + .read_candidate(&candidate.candidate_id, ExternalSessionImportMode::Full) + .unwrap(); + assert_eq!(document.candidate.title, candidate.title); + assert_eq!(document.turns[0].user_text, "Original question"); + assert_eq!( + reader.discover_all().unwrap().candidates[0].title, + candidate.title + ); + } + + #[test] + fn name_index_supports_older_state_schemas_and_refreshes_without_reading_rollouts() { + use std::io::Write; + let dir = fixture(vec![ + json!({"type":"session_meta","payload":{"id":"one","cwd":"/project"}}), + msg("user", "Original question"), + ]); + fs::create_dir(dir.path().join("sqlite")).unwrap(); + let connection = + rusqlite::Connection::open(dir.path().join("sqlite/state_4.sqlite")).unwrap(); + connection + .execute_batch( + "CREATE TABLE threads (id TEXT, title TEXT); + INSERT INTO threads VALUES ('one', 'Old database title');", + ) + .unwrap(); + let index = dir.path().join("session_index.jsonl"); + fs::write( + &index, + "{\"id\":\"one\",\"thread_name\":\"Initial name\"}\n", + ) + .unwrap(); + let reader = CodexSessionReader::new(dir.path().into()); + let first = reader.discover_all().unwrap().candidates.remove(0); + assert_eq!(first.title.as_deref(), Some("Initial name")); + reader.discover_all().unwrap(); + assert_eq!(reader.titles.lock().unwrap().reads, 1); + let mut writer = fs::OpenOptions::new().append(true).open(index).unwrap(); + writeln!( + writer, + "{}", + json!({"id":"one","thread_name":"Renamed source conversation"}) + ) + .unwrap(); + let renamed = reader.discover_all().unwrap().candidates.remove(0); + assert_eq!( + renamed.title.as_deref(), + Some("Renamed source conversation") + ); + assert_eq!( + reader + .header_reads + .load(std::sync::atomic::Ordering::Relaxed), + 1 + ); + assert_eq!(reader.titles.lock().unwrap().reads, 2); + } + + #[test] + fn source_rename_in_wal_invalidates_only_the_name_snapshot() { + let dir = fixture(vec![ + json!({"type":"session_meta","payload":{"id":"one","cwd":"/project"}}), + msg("user", "Original question"), + ]); + let database = dir.path().join("state_5.sqlite"); + let connection = rusqlite::Connection::open(&database).unwrap(); + connection + .execute_batch( + "PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; + CREATE TABLE threads (id TEXT, title TEXT, name TEXT); + INSERT INTO threads VALUES ('one', 'Original question', 'Saved name');", + ) + .unwrap(); + let reader = CodexSessionReader::new(dir.path().into()); + assert_eq!( + reader.discover_all().unwrap().candidates[0] + .title + .as_deref(), + Some("Saved name") + ); + let main_database = fs::read(&database).unwrap(); + connection + .execute( + "UPDATE threads SET name = ?1 WHERE id = 'one'", + ["Renamed while source is open"], + ) + .unwrap(); + let catalog = reader.discover_all().unwrap(); + assert_eq!( + catalog.candidates[0].title.as_deref(), + Some("Renamed while source is open") + ); + assert!(catalog.diagnostics.is_empty()); + assert_eq!(fs::read(database).unwrap(), main_database); + assert_eq!( + reader + .header_reads + .load(std::sync::atomic::Ordering::Relaxed), + 1 + ); + } + + #[test] + fn unavailable_optional_title_store_preserves_candidates_and_reports_a_warning() { + let dir = fixture(vec![ + json!({"type":"session_meta","payload":{"id":"one","cwd":"/project"}}), + msg("user", "Original question"), + ]); + fs::write(dir.path().join("state_5.sqlite"), b"Not a SQLite database").unwrap(); + fs::write( + dir.path().join("session_index.jsonl"), + "{\"id\":\"one\",\"thread_name\":\"Saved fallback name\"}\n", + ) + .unwrap(); + let catalog = CodexSessionReader::new(dir.path().into()) + .discover_all() + .unwrap(); + assert_eq!( + catalog.candidates[0].title.as_deref(), + Some("Saved fallback name") + ); + assert!(!catalog.candidates[0].title_is_source_name); + assert!(catalog + .diagnostics + .iter() + .any(|warning| warning.code == "source_titles_unreadable")); + } + + #[test] + fn unnamed_rollouts_use_the_human_request_without_context_envelopes() { + let context = "Available plugins"; + let dir = fixture(vec![ + json!({"type":"session_meta","payload":{"id":"one","cwd":"/project"}}), + msg("user", context), + msg("user", "# Files mentioned by the user:\n\nphoto.png\n\n## My request:\nExplain this screenshot"), + ]); + let catalog = CodexSessionReader::new(dir.path().into()) + .discover_all() + .unwrap(); + assert_eq!( + catalog.candidates[0].title.as_deref(), + Some("Explain this screenshot") + ); + assert_eq!( + catalog.candidates[0].fallback_title.as_deref(), + Some(context) + ); + assert!(!catalog.candidates[0].title_is_source_name); + } + + #[test] + #[ignore = "Reads installed Codex title metadata and rollout headers without printing conversation content"] + fn inspect_installed_codex_session_titles() { + let reader = CodexSessionReader::default(); + let catalog = reader.discover_all().unwrap(); + assert!(!catalog.candidates.is_empty()); + assert!( + catalog.diagnostics.is_empty(), + "{} source metadata warnings", + catalog.diagnostics.len() + ); + let named = catalog + .candidates + .iter() + .filter(|candidate| candidate.title_is_source_name) + .count(); + assert!(named > 0); + println!( + "Codex name inspection: {} candidates, {} saved source names, {} title-store reads", + catalog.candidates.len(), + named, + reader.titles.lock().unwrap().reads + ); + } + + #[test] + fn automatic_discovery_includes_sessions_after_the_ui_page_limit() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("sessions")).unwrap(); + for index in 0..57 { + fs::write(dir.path().join(format!("sessions/rollout-{index}.jsonl")), format!("{}\n{}\n", + json!({"type":"session_meta","payload":{"id":index.to_string(),"cwd":"/project"}}), + msg("user", "Saved question"))).unwrap(); + } + let reader = CodexSessionReader::new(dir.path().into()); + assert_eq!(reader.discover(0, 100, "").unwrap().candidates.len(), 50); + let all = reader.discover_all().unwrap(); + assert_eq!(all.candidates.len(), 57); + assert!(all.next_cursor.is_none()); + assert_eq!( + reader + .header_reads + .load(std::sync::atomic::Ordering::Relaxed), + 57 + ); + reader.discover_all().unwrap(); + assert_eq!( + reader + .header_reads + .load(std::sync::atomic::Ordering::Relaxed), + 57, + "Refreshing unchanged source files must not read their headers again" + ); + } + + #[test] + fn opening_a_discovered_session_does_not_rescan_unrelated_directories() { + let dir = fixture(vec![ + json!({"type":"session_meta","payload":{"id":"one","cwd":"/project"}}), + msg("user", "First question"), + msg("assistant", "Saved answer"), + ]); + let reader = CodexSessionReader::new(dir.path().into()); + let candidate = reader.discover_all().unwrap().candidates.remove(0); + // An unrelated excessive directory tree would fail a full rescan. + let mut nested = dir.path().join("sessions"); + for _ in 0..10 { + nested = nested.join("nested"); + } + fs::create_dir_all(nested).unwrap(); + assert!(reader.paths().is_err()); + let document = reader + .read_candidate(&candidate.candidate_id, ExternalSessionImportMode::Full) + .unwrap(); + assert_eq!(document.turns[0].user_text, "First question"); + } + + #[test] + fn changed_source_headers_invalidate_cached_metadata() { + let dir = fixture(vec![ + json!({"type":"session_meta","payload":{"id":"one","cwd":"/project"}}), + msg("user", "First question"), + ]); + let reader = CodexSessionReader::new(dir.path().into()); + let path = reader.paths().unwrap().remove(0); + let before = reader.inspect_path(&path).unwrap(); + fs::write( + &path, + format!( + "{}\n{}\n", + json!({"type":"session_meta","payload":{"id":"one","cwd":"/project"}}), + msg("user", "Changed and longer question") + ), + ) + .unwrap(); + let after = reader.inspect_path(&path).unwrap(); + assert_eq!(after.title.as_deref(), Some("Changed and longer question")); + assert_ne!(before.fingerprint, after.fingerprint); + assert_eq!( + reader + .header_reads + .load(std::sync::atomic::Ordering::Relaxed), + 2 + ); + } + + fn fixture(records: Vec) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("sessions")).unwrap(); + let mut lines = vec![json!({"type":"session_meta","payload":{"id":"s1","cwd":"/repo","timestamp":"2026-10-01T00:00:00Z"}}).to_string()]; + lines.extend(records.iter().map(Value::to_string)); + fs::write( + dir.path().join("sessions/rollout-test.jsonl"), + lines.join("\n") + "\n", + ) + .unwrap(); + dir + } + fn msg(role: &str, text: &str) -> Value { + json!({"type":"response_item","payload":{"type":"message","role":role,"content":[{"type":"input_text","text":text}]}}) + } + #[test] + fn deduplicates_mirrors_pairs_tools_and_preserves_turn_order() { + let dir = fixture(vec![ + msg("user", "first"), + json!({"type":"event_msg","payload":{"type":"user_message","message":"first"}}), + json!({"type":"response_item","payload":{"type":"function_call","call_id":"c1","name":"exec_command","arguments":"{\"cmd\":\"test\"}"}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"ok"}}), + msg("assistant", "done"), + msg("user", "second"), + msg("assistant", "next"), + ]); + let reader = CodexSessionReader::new(dir.path().into()); + let list = reader.discover(0, 50, "").unwrap(); + assert_eq!(list.candidates.len(), 1); + assert_eq!(list.candidates[0].turn_count, None); + let doc = reader + .read_candidate( + &list.candidates[0].candidate_id, + ExternalSessionImportMode::Full, + ) + .unwrap(); + assert_eq!(doc.turns.len(), 2); + assert_eq!(doc.turns[0].items.len(), 2); + assert!( + matches!(&doc.turns[0].items[0], ExternalSessionItem::Tool { input, result: Some(_), .. } if input["cmd"] == "test") + ); + } + #[test] + fn compaction_replaces_context_and_rollback_removes_suffix() { + let dir = fixture(vec![ + msg("user", "old"), + msg("assistant", "old reply"), + json!({"type":"compacted","payload":{"replacement_history":[{"type":"message","role":"user","content":"summary"}]}}), + msg("user", "discard"), + json!({"type":"event_msg","payload":{"type":"thread_rolled_back","num_turns":1}}), + msg("user", "keep"), + ]); + let reader = CodexSessionReader::new(dir.path().into()); + let list = reader.discover(0, 50, "").unwrap(); + let doc = reader + .read_candidate( + &list.candidates[0].candidate_id, + ExternalSessionImportMode::LatestCompacted, + ) + .unwrap(); + assert_eq!( + doc.turns + .iter() + .map(|t| t.user_text.as_str()) + .collect::>(), + vec!["summary", "keep"] + ); + } + #[test] + fn encrypted_compaction_requires_full_history_fallback() { + let dir = fixture(vec![ + msg("user", "before"), + msg("assistant", "answer"), + json!({"type":"response_item","payload":{"type":"compaction","id":"compact-1"}}), + msg("user", "after"), + msg("assistant", "done"), + ]); + let reader = CodexSessionReader::new(dir.path().into()); + let list = reader.discover(0, 50, "").unwrap(); + let id = &list.candidates[0].candidate_id; + let error = match reader.read_candidate(id, ExternalSessionImportMode::LatestCompacted) { + Ok(_) => panic!("latest compacted history should require a readable fallback"), + Err(error) => error, + }; + assert!(error.contains("encrypted context summary")); + let document = reader + .read_candidate(id, ExternalSessionImportMode::Full) + .unwrap(); + assert_eq!(document.turns.len(), 2); + assert!(document + .warnings + .iter() + .any(|warning| warning.code == "compaction_present")); + } + #[test] + fn corrupt_middle_fails_and_torn_tail_is_disclosed() { + let dir = fixture(vec![msg("user", "hello")]); + let p = dir.path().join("sessions/rollout-test.jsonl"); + let reader = CodexSessionReader::new(dir.path().into()); + let mut bytes = fs::read(&p).unwrap(); + bytes.extend_from_slice(b"{broken"); + fs::write(&p, &bytes).unwrap(); + let doc = reader + .read_path(&p, ExternalSessionImportMode::Full) + .unwrap(); + assert!(doc.warnings.iter().any(|w| w.code == "torn_tail")); + bytes.extend_from_slice(b"\n"); + fs::write(&p, bytes).unwrap(); + assert!(reader + .read_path(&p, ExternalSessionImportMode::Full) + .is_err()); + } +} diff --git a/src/crates/adapters/codex-adapter/src/session/titles.rs b/src/crates/adapters/codex-adapter/src/session/titles.rs new file mode 100644 index 0000000000..b0c3e5acb5 --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/session/titles.rs @@ -0,0 +1,271 @@ +//! Codex keeps display names outside its rollout files. Read one small, +//! cached metadata snapshot per discovery, including committed WAL records. +use super::*; +use rusqlite::{Connection, OpenFlags}; +use std::time::Duration; + +const MAX_INDEX_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Default)] +pub(super) struct TitleSnapshot { + names: HashMap, + indexed_names: HashMap, + titles: HashMap, + rollout_paths: HashMap, + pub(super) diagnostics: Vec, +} + +impl TitleSnapshot { + pub(super) fn current_rollout(&self, source_id: &str) -> Option<&str> { + self.rollout_paths.get(source_id).map(String::as_str) + } + + pub(super) fn is_current_rollout(&self, source_id: &str, path: &Path) -> bool { + self.rollout_paths.get(source_id).is_some_and(|current| { + rollout_path_key(current) == rollout_path_key(&path.to_string_lossy()) + }) + } + + pub(super) fn apply(&self, candidate: &mut ExternalSessionCandidate) { + if let Some(title) = self + .names + .get(&candidate.source_session_id) + .or_else(|| self.indexed_names.get(&candidate.source_session_id)) + .or_else(|| self.titles.get(&candidate.source_session_id)) + { + candidate.title = Some(title.clone()); + candidate.title_is_source_name = self.diagnostics.is_empty(); + } + } +} + +fn rollout_path_key(path: &str) -> String { + let path = path.strip_prefix("\\\\?\\").unwrap_or(path); + if cfg!(windows) { + path.replace('\\', "/").to_lowercase() + } else { + path.to_owned() + } +} + +type Stamp = Vec<(PathBuf, Option, u64)>; + +#[derive(Default)] +pub(super) struct TitleCache { + cached: Option<(Stamp, Arc)>, + #[cfg(test)] + pub(super) reads: usize, +} + +impl TitleCache { + pub(super) fn snapshot(&mut self, root: &Path) -> Arc { + let mut snapshot = TitleSnapshot::default(); + let databases = match database_paths(root) { + Ok(paths) => paths, + Err(error) => { + snapshot + .diagnostics + .push(warning("source_titles_unreadable", &error)); + Vec::new() + } + }; + let index = root.join("session_index.jsonl"); + let mut stamp_paths = vec![index.clone()]; + for path in &databases { + stamp_paths.push(path.clone()); + stamp_paths.push(path.with_file_name(format!( + "{}-wal", + path.file_name().unwrap_or_default().to_string_lossy(), + ))); + } + let stamp = stamp_paths + .into_iter() + .map(|path| { + let metadata = fs::metadata(&path).ok(); + let modified = metadata + .as_ref() + .and_then(|metadata| metadata.modified().ok()); + let size = metadata.map_or(0, |metadata| metadata.len()); + (path, modified, size) + }) + .collect::>(); + if snapshot.diagnostics.is_empty() { + if let Some((previous, snapshot)) = &self.cached { + if previous == &stamp { + return snapshot.clone(); + } + } + } + #[cfg(test)] + { + self.reads += 1; + } + if let Err(error) = read_index(&index, &mut snapshot.indexed_names) { + snapshot + .diagnostics + .push(warning("source_titles_unreadable", &error)); + } + // Current Codex stores state at the home root. Older installations may + // keep it under sqlite/; they only fill names absent from the root DB. + for database in databases { + if let Err(error) = read_database(&database, &mut snapshot) { + snapshot + .diagnostics + .push(warning("source_titles_unreadable", &error)); + } + } + let snapshot = Arc::new(snapshot); + if snapshot.diagnostics.is_empty() { + self.cached = Some((stamp, snapshot.clone())); + } + snapshot + } +} + +fn database_paths(root: &Path) -> Result, String> { + let mut paths = Vec::new(); + for directory in [root.to_path_buf(), root.join("sqlite")] { + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(format!("Codex title store cannot be listed: {error}")), + }; + let mut latest = None; + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + let filename = entry.file_name(); + let filename = filename.to_string_lossy(); + let Some(version) = filename + .strip_prefix("state_") + .and_then(|name| name.strip_suffix(".sqlite")) + .and_then(|version| version.parse::().ok()) + else { + continue; + }; + if entry + .file_type() + .map_err(|error| error.to_string())? + .is_file() + && latest + .as_ref() + .is_none_or(|(previous, _)| version > *previous) + { + latest = Some((version, entry.path())); + } + } + if let Some((_, path)) = latest { + paths.push(path); + } + } + Ok(paths) +} + +fn read_index(path: &Path, names: &mut HashMap) -> Result<(), String> { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("Codex session name index cannot be read: {error}")), + }; + if file.metadata().map_err(|error| error.to_string())?.len() > MAX_INDEX_BYTES { + return Err("Codex session name index exceeds the 32 MiB read limit".into()); + } + let mut malformed = false; + for line in BufReader::new(file.take(MAX_INDEX_BYTES)).lines() { + let line = line.map_err(|error| error.to_string())?; + if line.trim().is_empty() { + continue; + } + let value: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(_) => { + malformed = true; + continue; + } + }; + if let (Some(id), Some(name)) = ( + value["id"].as_str(), + value["thread_name"] + .as_str() + .filter(|name| !name.trim().is_empty()), + ) { + // The JSONL index is append-only; the last named entry wins. + names.insert(id.to_string(), name.to_string()); + } + } + if malformed { + return Err("Codex session name index contains unreadable entries".into()); + } + Ok(()) +} + +fn read_database(path: &Path, snapshot: &mut TitleSnapshot) -> Result<(), String> { + let read_error = |error| format!("Codex session title database cannot be read: {error}"); + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(read_error)?; + connection + .busy_timeout(Duration::from_secs(2)) + .map_err(read_error)?; + // Do not set immutable=1: a running Codex can keep its latest name in WAL. + connection + .execute_batch("PRAGMA query_only = ON; BEGIN DEFERRED TRANSACTION;") + .map_err(read_error)?; + let columns = connection + .prepare("PRAGMA table_info(threads)") + .map_err(read_error)? + .query_map([], |row| row.get::<_, String>(1)) + .map_err(read_error)? + .collect::, _>>() + .map_err(read_error)?; + if !columns.contains("id") || (!columns.contains("name") && !columns.contains("title")) { + return Err("Unsupported Codex title metadata schema; using available name index and rollout headers".into()); + } + let name = if columns.contains("name") { + "name" + } else { + "NULL" + }; + let title = if columns.contains("title") { + "title" + } else { + "NULL" + }; + let rollout_path = if columns.contains("rollout_path") { + "rollout_path" + } else { + "NULL" + }; + let mut query = connection + .prepare(&format!( + "SELECT id, {name}, {title}, {rollout_path} FROM threads ORDER BY id LIMIT ?1", + )) + .map_err(read_error)?; + let rows = query + .query_map([MAX_FILES as i64 + 1], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }) + .map_err(read_error)?; + for (index, row) in rows.enumerate() { + if index >= MAX_FILES { + return Err("Codex title catalog exceeds the discovery limit".into()); + } + let (id, name, title, rollout_path) = row.map_err(read_error)?; + if let Some(path) = rollout_path.filter(|path| !path.trim().is_empty()) { + snapshot.rollout_paths.entry(id.clone()).or_insert(path); + } + if let Some(name) = name.filter(|name| !name.trim().is_empty()) { + snapshot.names.entry(id.clone()).or_insert(name); + } + if let Some(title) = title.filter(|title| !title.trim().is_empty()) { + snapshot.titles.entry(id).or_insert(title); + } + } + Ok(()) +} diff --git a/src/crates/adapters/zcode-adapter/AGENTS.md b/src/crates/adapters/zcode-adapter/AGENTS.md new file mode 100644 index 0000000000..38f7de88af --- /dev/null +++ b/src/crates/adapters/zcode-adapter/AGENTS.md @@ -0,0 +1,23 @@ +# Zcode Adapter + +This adapter reads Zcode's local `~/.zcode/cli/db/db.sqlite` session store. +It owns source schema, visibility, ordering and tool-result translation only. +Keep native import, workspace registration and continuation in their existing +product owners. Do not launch Zcode, execute imported tools, load credentials, +change SQLite schema or write to the source database. Use read-only SQLite +transactions so committed WAL records and message parts form one snapshot. + +Only top-level task sessions are listed. Inherited hidden context, system +reminders and nested/side tasks are not additional human conversation turns. +Missing stores are empty; unreadable or unsupported stores must report errors. +Unknown optional columns are tolerated; required-column loss is unsupported. + +```bash +cargo test -p openbitfun-zcode-adapter --lib session::tests +``` + +Optional local read-only verification (prints counts, never conversation text): + +```bash +cargo test -p openbitfun-zcode-adapter --lib inspect_installed_zcode -- --ignored --nocapture +``` diff --git a/src/crates/adapters/zcode-adapter/Cargo.toml b/src/crates/adapters/zcode-adapter/Cargo.toml new file mode 100644 index 0000000000..642ec4343a --- /dev/null +++ b/src/crates/adapters/zcode-adapter/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "openbitfun-zcode-adapter" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Read-only Zcode session adapter for OpenBitFun" +autotests = false + +[lib] +name = "openbitfun_zcode_adapter" +crate-type = ["rlib"] + +[dependencies] +openbitfun-product-domains = { path = "../../contracts/product-domains", features = ["external-sources"] } +dirs = { workspace = true } +hex = { workspace = true } +rusqlite = { workspace = true, features = ["bundled"] } +serde_json = { workspace = true } +sha2 = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/crates/adapters/zcode-adapter/src/lib.rs b/src/crates/adapters/zcode-adapter/src/lib.rs new file mode 100644 index 0000000000..23edc1d534 --- /dev/null +++ b/src/crates/adapters/zcode-adapter/src/lib.rs @@ -0,0 +1,2 @@ +//! Read-only Zcode source translation. Does not launch Zcode or execute its tools. +pub mod session; diff --git a/src/crates/adapters/zcode-adapter/src/session.rs b/src/crates/adapters/zcode-adapter/src/session.rs new file mode 100644 index 0000000000..b107f28c63 --- /dev/null +++ b/src/crates/adapters/zcode-adapter/src/session.rs @@ -0,0 +1,511 @@ +//! Bounded translation of Zcode's persisted session/message/part database. +use openbitfun_product_domains::session_import::*; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeSet, HashMap}; +use std::path::PathBuf; +use std::time::Duration; + +pub const SOURCE_REVISION: &str = "zcode-session-sqlite-v1"; +const MAX_SESSIONS: usize = 100_000; +const MAX_RECORDS: usize = 200_000; +const MAX_RECORD_BYTES: u64 = 8 * 1024 * 1024; +const MAX_DOCUMENT_BYTES: u64 = 128 * 1024 * 1024; + +#[derive(Clone)] +pub struct ZcodeSessionReader { + pub zcode_home: PathBuf, +} + +impl Default for ZcodeSessionReader { + fn default() -> Self { + Self::new( + dirs::home_dir() + .map(|home| home.join(".zcode")) + .unwrap_or_default(), + ) + } +} + +struct StoreSchema { + message_sequence: bool, + part_sequence: bool, +} + +struct Record { + id: String, + message_id: Option, + created: u64, + data: Value, +} + +impl ZcodeSessionReader { + pub fn new(zcode_home: PathBuf) -> Self { + Self { zcode_home } + } + + fn open(&self) -> Result, String> { + if !self.zcode_home.is_absolute() { + return Err("Zcode home must be an absolute host path".into()); + } + let path = self.zcode_home.join("cli/db/db.sqlite"); + match std::fs::metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Zcode session database is unreadable: {error}")), + Ok(metadata) if !metadata.is_file() => { + return Err("Zcode session database is not a file".into()) + } + Ok(_) => {} + } + // Do not use immutable=1: Zcode may still have committed history in WAL. + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(database_error)?; + connection + .busy_timeout(Duration::from_secs(2)) + .map_err(database_error)?; + connection + .execute_batch("PRAGMA query_only = ON; BEGIN DEFERRED TRANSACTION;") + .map_err(database_error)?; + let session = columns(&connection, "session")?; + let message = columns(&connection, "message")?; + let part = columns(&connection, "part")?; + for (found, required) in [ + ( + &session, + &[ + "id", + "parent_id", + "directory", + "title", + "version", + "time_created", + "time_updated", + ][..], + ), + (&message, &["id", "session_id", "time_created", "data"][..]), + ( + &part, + &["id", "message_id", "session_id", "time_created", "data"][..], + ), + ] { + if required.iter().any(|field| !found.contains(*field)) { + return Err("Unsupported Zcode session database schema; update OpenBitFun to read this format".into()); + } + } + Ok(Some(( + connection, + StoreSchema { + message_sequence: message.contains("sequence"), + part_sequence: part.contains("sequence"), + }, + ))) + } + + pub fn discover( + &self, + cursor: usize, + limit: usize, + query: &str, + ) -> Result { + self.discover_page(cursor, limit.clamp(1, 50), query) + } + + /// Read one bounded SQLite catalog snapshot for host-owned discovery. + pub fn discover_all(&self) -> Result { + self.discover_page(0, usize::MAX, "") + } + + fn discover_page( + &self, + cursor: usize, + limit: usize, + query: &str, + ) -> Result { + let mut catalog = ExternalSessionCatalog { + schema_version: EXTERNAL_SESSION_IMPORT_SCHEMA_V1, + ecosystem_id: "zcode".into(), + source_revision: SOURCE_REVISION.into(), + candidates: Vec::new(), + diagnostics: Vec::new(), + next_cursor: None, + }; + let Some((connection, _)) = self.open()? else { + return Ok(catalog); + }; + let mut statement = connection + .prepare( + "SELECT id, directory, title, version, time_created, time_updated FROM session \ + WHERE parent_id IS NULL ORDER BY time_updated DESC, id LIMIT ?1", + ) + .map_err(database_error)?; + let rows = statement + .query_map([MAX_SESSIONS as i64 + 1], candidate_from_row) + .map_err(database_error)?; + let query = query.trim().to_lowercase(); + let mut candidates = Vec::new(); + for (index, row) in rows.enumerate() { + if index >= MAX_SESSIONS { + return Err("Zcode session catalog exceeds the discovery limit".into()); + } + let candidate = row.map_err(database_error)?; + if query.is_empty() + || format!( + "{} {}", + candidate.title.as_deref().unwrap_or(""), + candidate.cwd.as_deref().unwrap_or("") + ) + .to_lowercase() + .contains(&query) + { + candidates.push(candidate); + } + } + let end = cursor.saturating_add(limit).min(candidates.len()); + catalog.next_cursor = (end < candidates.len()).then_some(end); + catalog.candidates = candidates.into_iter().skip(cursor).take(limit).collect(); + Ok(catalog) + } + + pub fn read_candidate( + &self, + candidate_id: &str, + mode: ExternalSessionImportMode, + ) -> Result { + if mode != ExternalSessionImportMode::Full { + return Err("Zcode session import supports full history only".into()); + } + let id = candidate_id + .strip_prefix("zcode:") + .filter(|id| !id.is_empty() && id.len() <= 256) + .ok_or("Invalid Zcode session candidate ID")?; + let (connection, schema) = self + .open()? + .ok_or("Zcode session database was removed or moved; refresh the list")?; + let mut candidate = connection.query_row( + "SELECT id, directory, title, version, time_created, time_updated FROM session WHERE id = ?1 AND parent_id IS NULL", + [id], candidate_from_row, + ).optional().map_err(database_error)?.ok_or("Zcode session was removed or moved; refresh the list")?; + + let mut total_bytes = 0u64; + for table in ["message", "part"] { + let (count, bytes, largest): (u64, u64, u64) = connection.query_row( + &format!("SELECT count(*), coalesce(sum(length(cast(data as blob))), 0), coalesce(max(length(cast(data as blob))), 0) FROM {table} WHERE session_id = ?1"), + [id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ).map_err(database_error)?; + total_bytes = total_bytes.saturating_add(bytes); + if count > MAX_RECORDS as u64 + || largest > MAX_RECORD_BYTES + || total_bytes > MAX_DOCUMENT_BYTES + { + return Err("Zcode session exceeds the safe import size limit".into()); + } + } + let mut hasher = Sha256::new(); + hasher.update(SOURCE_REVISION.as_bytes()); + hasher.update(serde_json::to_vec(&candidate).map_err(|error| error.to_string())?); + let messages = records( + &connection, + "message", + id, + schema.message_sequence, + &mut hasher, + )?; + let parts = records(&connection, "part", id, schema.part_sequence, &mut hasher)?; + let mut parts_by_message: HashMap> = HashMap::new(); + for part in parts { + parts_by_message + .entry(part.message_id.clone().unwrap_or_default()) + .or_default() + .push(part); + } + let mut turns: Vec = Vec::new(); + let mut user_turns = HashMap::new(); + let mut hidden_users = BTreeSet::new(); + let mut warnings = BTreeSet::new(); + for message in messages { + let parts = parts_by_message.remove(&message.id).unwrap_or_default(); + let data = &message.data; + if hidden_message(data) { + if data["role"] == "user" { + hidden_users.insert(message.id); + } + continue; + } + match data["role"].as_str() { + Some("user") => { + let mut text = Vec::new(); + for part in &parts { + if part.data["synthetic"] == true || part.data["ignored"] == true { + continue; + } + match part.data["type"].as_str() { + Some("text") => { + if let Some(value) = part.data["text"] + .as_str() + .filter(|text| !text.trim().is_empty()) + { + text.push(value.to_owned()); + } + } + Some("file") => { + let name = part.data["filename"].as_str().unwrap_or("attachment"); + text.push(format!("[Source attachment: {name}]")); + warnings.insert("attachment_reference_only"); + } + Some("compaction") => { + warnings.insert("compaction_marker"); + } + Some(_) => { + warnings.insert("unsupported_part"); + } + None => return Err("Malformed Zcode session part: missing type".into()), + } + } + if text.is_empty() { + hidden_users.insert(message.id); + continue; + } + user_turns.insert(message.id.clone(), turns.len()); + turns.push(ExternalSessionTurn { + source_turn_id: message.id, + timestamp: data["time"]["created"].as_u64().unwrap_or(message.created), + user_text: text.join("\n\n"), + items: Vec::new(), + source_event_count: 1 + parts.len(), + status: ExternalSessionStatus::Interrupted, + }); + } + Some("assistant") => { + if data["semantics"]["kind"] == "timeline_event" { + continue; + } + let index = match data.get("parentID").filter(|value| !value.is_null()) { + Some(parent) => { + let id = parent + .as_str() + .ok_or("Malformed Zcode session: invalid assistant parent ID")?; + if hidden_users.contains(id) { + continue; + } + user_turns.get(id).copied() + } + None => turns.len().checked_sub(1), + }; + let Some(index) = index else { + if parts.iter().any(|part| { + matches!( + part.data["type"].as_str(), + Some("text" | "tool" | "reasoning") + ) + }) { + return Err("Malformed Zcode session: assistant history has no readable user turn".into()); + } + continue; + }; + let turn = &mut turns[index]; + turn.source_event_count += 1 + parts.len(); + candidate.model_provider = + string(data.get("providerId")).or(candidate.model_provider); + candidate.model_name = string(data.get("modelId")).or(candidate.model_name); + let mut unfinished_tool = false; + for part in parts { + if part.data["ignored"] == true { + continue; + } + match part.data["type"].as_str() { + Some("text" | "reasoning") => { + if let Some(text) = + part.data["text"].as_str().filter(|text| !text.is_empty()) + { + turn.items.push(if part.data["type"] == "reasoning" { + ExternalSessionItem::Thinking { + text: text.to_owned(), + } + } else { + ExternalSessionItem::Text { + text: text.to_owned(), + } + }); + } + } + Some("tool") => { + let state = &part.data["state"]; + let (result, success) = match state["status"].as_str() { + Some("completed") => (state.get("output").cloned(), Some(true)), + Some("error") => (state.get("error").cloned(), Some(false)), + Some("pending" | "running") => { unfinished_tool = true; (None, None) } + _ => return Err("Unsupported Zcode tool state; update OpenBitFun to read this format".into()), + }; + turn.items.push(ExternalSessionItem::Tool { + id: part.data["callID"].as_str().unwrap_or(&part.id).to_owned(), + name: part.data["tool"] + .as_str() + .ok_or("Malformed Zcode tool record: missing name")? + .to_owned(), + input: state.get("input").cloned().unwrap_or(Value::Null), + result, + success, + }); + } + Some( + "step-start" | "step-finish" | "timeline" | "snapshot" | "patch", + ) => {} + Some(_) => { + warnings.insert("unsupported_part"); + } + None => return Err("Malformed Zcode session part: missing type".into()), + } + } + turn.status = if data.get("error").is_none_or(Value::is_null) + && matches!(data["finish"].as_str(), Some("stop" | "end_turn")) + && !unfinished_tool + { + ExternalSessionStatus::Complete + } else { + ExternalSessionStatus::Interrupted + }; + } + Some("system") => {} + _ => { + return Err( + "Unsupported Zcode message role; update OpenBitFun to read this format" + .into(), + ) + } + } + } + if !parts_by_message.is_empty() { + return Err("Malformed Zcode session: orphaned message parts".into()); + } + if turns.is_empty() { + return Err("No usable human conversation was found in this Zcode session".into()); + } + candidate.status = if turns + .iter() + .any(|turn| turn.status == ExternalSessionStatus::Interrupted) + { + ExternalSessionStatus::Interrupted + } else { + ExternalSessionStatus::Complete + }; + candidate.turn_count = Some(turns.len()); + candidate.file_size_bytes = total_bytes; + candidate.fingerprint = format!("sha256:{}", hex::encode(hasher.finalize())); + Ok(ExternalSessionDocument { + candidate, turns, + warnings: warnings.into_iter().map(|code| ExternalSessionImportWarning { + code: format!("zcode_{code}"), + detail: match code { + "attachment_reference_only" => "Attachment names were preserved; source attachment files were not copied.", + "compaction_marker" => "Compaction control records are omitted; retained conversation history is imported.", + _ => "Some Zcode display or control parts have no supported history representation.", + }.into(), + }).collect(), + }) + } +} + +fn columns(connection: &Connection, table: &str) -> Result, String> { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(database_error)?; + let columns = statement + .query_map([], |row| row.get(1)) + .map_err(database_error)? + .collect::>() + .map_err(database_error)?; + Ok(columns) +} + +fn database_error(error: rusqlite::Error) -> String { + format!("Could not read Zcode session database: {error}") +} + +fn candidate_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id: String = row.get(0)?; + let updated_at: Option = row.get(5)?; + Ok(ExternalSessionCandidate { + candidate_id: format!("zcode:{id}"), + ecosystem_id: "zcode".into(), + source_session_id: id, + cwd: row.get(1)?, + title: row.get(2)?, + fallback_title: None, + title_is_source_name: true, + format_version: row.get(3)?, + created_at: row.get(4)?, + updated_at, + model_provider: None, + model_name: None, + turn_count: None, + file_size_bytes: 0, + status: ExternalSessionStatus::Complete, + fingerprint: format!("metadata:{}", updated_at.unwrap_or(0)), + source_location: Some("~/.zcode/cli/db/db.sqlite".into()), + target_workspace_id: None, + target_workspace_name: None, + imported_session_id: None, + }) +} + +fn records( + connection: &Connection, + table: &str, + id: &str, + sequence: bool, + hasher: &mut Sha256, +) -> Result, String> { + let order = if sequence { + "coalesce(sequence, time_created), time_created, id" + } else { + "time_created, id" + }; + let parent = if table == "part" { + "message_id" + } else { + "NULL" + }; + let mut statement = connection.prepare(&format!("SELECT id, {parent}, time_created, data FROM {table} WHERE session_id = ?1 ORDER BY {order}")).map_err(database_error)?; + let mut rows = statement.query([id]).map_err(database_error)?; + let mut result = Vec::new(); + while let Some(row) = rows.next().map_err(database_error)? { + let id: String = row.get(0).map_err(database_error)?; + let message_id: Option = row.get(1).map_err(database_error)?; + let raw: String = row.get(3).map_err(database_error)?; + hasher.update(table.as_bytes()); + hasher.update(id.as_bytes()); + hasher.update(raw.as_bytes()); + let data: Value = + serde_json::from_str(&raw).map_err(|_| "Malformed Zcode session record".to_string())?; + if !data.is_object() { + return Err("Malformed Zcode session record".into()); + } + result.push(Record { + id, + message_id, + created: row.get(2).map_err(database_error)?, + data, + }); + } + Ok(result) +} + +fn hidden_message(data: &Value) -> bool { + data["semantics"]["uiVisibility"] == "hidden" + || data["semantics"]["transcriptVisibility"] == "hidden" + || matches!( + data["semantics"]["kind"].as_str(), + Some("system_reminder" | "todo_reminder") + ) +} + +fn string(value: Option<&Value>) -> Option { + value.and_then(Value::as_str).map(str::to_owned) +} + +#[cfg(test)] +mod tests; diff --git a/src/crates/adapters/zcode-adapter/src/session/tests.rs b/src/crates/adapters/zcode-adapter/src/session/tests.rs new file mode 100644 index 0000000000..0794f1ad05 --- /dev/null +++ b/src/crates/adapters/zcode-adapter/src/session/tests.rs @@ -0,0 +1,433 @@ +use super::*; +use rusqlite::params; +use serde_json::json; + +struct Fixture { + _root: tempfile::TempDir, + reader: ZcodeSessionReader, + writer: Connection, +} + +impl Fixture { + fn new(sequence: bool) -> Self { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("cli/db")).unwrap(); + let writer = Connection::open(root.path().join("cli/db/db.sqlite")).unwrap(); + writer.execute_batch("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; + CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, title TEXT, version TEXT, time_created INTEGER, time_updated INTEGER); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);").unwrap(); + if sequence { + writer.execute_batch("ALTER TABLE message ADD COLUMN sequence INTEGER; ALTER TABLE part ADD COLUMN sequence INTEGER;").unwrap(); + } + Self { + reader: ZcodeSessionReader::new(root.path().to_owned()), + _root: root, + writer, + } + } + fn session(&self, id: &str, cwd: &str, updated: u64) { + self.writer + .execute( + "INSERT INTO session VALUES (?1, NULL, ?2, ?1, '0.16.9', 1000, ?3)", + params![id, cwd, updated], + ) + .unwrap(); + } + fn message(&self, session: &str, id: &str, order: u64, data: Value) { + self.writer + .execute( + "INSERT INTO message (id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4)", + params![id, session, order, data.to_string()], + ) + .unwrap(); + if columns(&self.writer, "message") + .unwrap() + .contains("sequence") + { + self.writer + .execute( + "UPDATE message SET sequence = ?1 WHERE id = ?2", + params![order, id], + ) + .unwrap(); + } + } + fn part(&self, session: &str, message: &str, id: &str, order: u64, data: Value) { + self.writer.execute("INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)", params![id, message, session, order, data.to_string()]).unwrap(); + if columns(&self.writer, "part").unwrap().contains("sequence") { + self.writer + .execute( + "UPDATE part SET sequence = ?1 WHERE id = ?2", + params![order, id], + ) + .unwrap(); + } + } + fn conversation(&self, id: &str) { + self.session(id, "/project", 1000); + self.message(id, &format!("{id}-u"), 1, json!({"role":"user", "time":{"created":1001}, "semantics":{"origin":"real_user","kind":"user_prompt"}})); + self.part( + id, + &format!("{id}-u"), + &format!("{id}-p1"), + 1, + json!({"type":"text","text":"Inspect the project"}), + ); + self.message(id, &format!("{id}-a"), 2, json!({"role":"assistant","parentID":format!("{id}-u"),"modelId":"glm-fixture","providerId":"zcode-fixture","finish":"stop"})); + self.part( + id, + &format!("{id}-a"), + &format!("{id}-p2"), + 2, + json!({"type":"text","text":"Done"}), + ); + } + fn read(&self, id: &str) -> ExternalSessionDocument { + self.reader + .read_candidate(&format!("zcode:{id}"), ExternalSessionImportMode::Full) + .unwrap() + } +} + +#[test] +fn missing_database_is_empty_without_creating_files() { + let root = tempfile::tempdir().unwrap(); + let reader = ZcodeSessionReader::new(root.path().to_owned()); + let catalog = reader.discover_all().unwrap(); + assert!(catalog.candidates.is_empty()); + assert!(!root.path().join("cli").exists()); +} + +#[test] +fn automatic_discovery_reads_all_committed_sessions_beyond_the_ui_page() { + let fixture = Fixture::new(true); + for index in 0..57 { + fixture.session(&format!("session-{index}"), "/project", index); + } + assert_eq!( + fixture + .reader + .discover(0, 100, "") + .unwrap() + .candidates + .len(), + 50 + ); + let all = fixture.reader.discover_all().unwrap(); + assert_eq!(all.candidates.len(), 57); + assert!(all.next_cursor.is_none()); +} + +#[test] +fn discovery_follows_recent_activity_and_excludes_nested_tasks() { + let fixture = Fixture::new(true); + for i in 0..8 { + fixture.session(&format!("a-{i}"), "C:\\Project", 100 + i); + } + fixture.session("a-new", "c:/project/", 300); + fixture.session("b", "/b", 200); + fixture.session("a-other", "C:/Project-other", 150); + fixture.session("side", "/side", 999); + fixture + .writer + .execute( + "UPDATE session SET parent_id = 'a-new' WHERE id = 'side'", + [], + ) + .unwrap(); + let catalog = fixture.reader.discover_all().unwrap(); + assert_eq!(catalog.candidates.len(), 11); + assert_eq!(catalog.candidates[0].source_session_id, "a-new"); + assert_eq!(catalog.candidates[1].source_session_id, "b"); + assert!(catalog + .candidates + .iter() + .all(|candidate| candidate.source_session_id != "side")); + assert!(fixture + .reader + .read_candidate("zcode:side", ExternalSessionImportMode::Full) + .is_err()); +} + +#[test] +fn preserves_order_raw_tool_payloads_and_failed_results_without_runtime_reminders() { + let fixture = Fixture::new(true); + fixture.conversation("main"); + fixture.message( + "main", + "hidden", + 2, + json!({"role":"user","semantics":{"kind":"todo_reminder","uiVisibility":"hidden"}}), + ); + fixture.part( + "main", + "hidden", + "hidden-part", + 3, + json!({"type":"text","text":"internal reminder","synthetic":true}), + ); + fixture.part( + "main", + "main-a", + "reasoning", + 0, + json!({"type":"reasoning","text":"Reasoning history"}), + ); + let input = json!({"command":"echo fixture", "timeout":1000}); + fixture.part("main", "main-a", "tool", 1, json!({"type":"tool","callID":"call-1","tool":"Bash","state":{"status":"completed","input":input,"output":{"stdout":"fixture","exitCode":0}}})); + fixture.part("main", "main-a", "failed-tool", 3, json!({"type":"tool","callID":"call-2","tool":"Read","state":{"status":"error","input":{"path":"missing"},"error":"File not found"}})); + let document = fixture.read("main"); + assert_eq!(document.turns.len(), 1); + assert_eq!(document.turns[0].user_text, "Inspect the project"); + assert_eq!(document.turns[0].timestamp, 1001); + assert_eq!( + document.candidate.model_name.as_deref(), + Some("glm-fixture") + ); + let items = &document.turns[0].items; + assert!( + matches!(&items[0], ExternalSessionItem::Thinking { text } if text == "Reasoning history") + ); + assert!( + matches!(&items[1], ExternalSessionItem::Tool { id, input: actual, result: Some(result), success: Some(true), .. } + if id == "call-1" && actual == &input && result["stdout"] == "fixture") + ); + assert!(matches!(&items[2], ExternalSessionItem::Text { text } if text == "Done")); + assert!( + matches!(&items[3], ExternalSessionItem::Tool { result: Some(Value::String(error)), success: Some(false), .. } if error == "File not found") + ); +} + +#[test] +fn parent_ids_keep_interleaved_replies_with_their_original_user_turns() { + let fixture = Fixture::new(true); + fixture.conversation("main"); + fixture.message("main", "later-user", 2, json!({"role":"user"})); + fixture.part( + "main", + "later-user", + "later-text", + 1, + json!({"type":"text","text":"Follow-up"}), + ); + fixture + .writer + .execute("UPDATE message SET sequence=3 WHERE id='main-a'", []) + .unwrap(); + let document = fixture.read("main"); + assert_eq!(document.turns.len(), 2); + assert_eq!(document.turns[0].items.len(), 1); + assert!(document.turns[1].items.is_empty()); + assert_eq!(document.turns[1].status, ExternalSessionStatus::Interrupted); +} + +#[test] +fn reads_committed_wal_and_uses_one_read_only_snapshot() { + let fixture = Fixture::new(true); + fixture.conversation("main"); + assert!(fixture._root.path().join("cli/db/db.sqlite-wal").exists()); + let (connection, _) = fixture.reader.open().unwrap().unwrap(); + assert!(connection + .is_readonly(rusqlite::DatabaseName::Main) + .unwrap()); + fixture + .writer + .execute( + "UPDATE part SET data = ?1 WHERE id='main-p2'", + [json!({"type":"text","text":"New committed answer"}).to_string()], + ) + .unwrap(); + let old: String = connection + .query_row("SELECT data FROM part WHERE id='main-p2'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(serde_json::from_str::(&old).unwrap()["text"], "Done"); + assert!(connection.execute("DELETE FROM session", []).is_err()); + assert!( + matches!(&fixture.read("main").turns[0].items[0], ExternalSessionItem::Text { text } if text == "New committed answer") + ); +} + +#[test] +fn unknown_parent_ids_cannot_attach_history_to_an_unrelated_user_turn() { + let fixture = Fixture::new(true); + fixture.conversation("main"); + fixture + .writer + .execute( + "UPDATE message SET data = ?1 WHERE id='main-a'", + [json!({"role":"assistant","parentID":"missing-user","finish":"stop"}).to_string()], + ) + .unwrap(); + assert!(fixture + .reader + .read_candidate("zcode:main", ExternalSessionImportMode::Full) + .err() + .unwrap() + .contains("no readable user turn")); + fixture + .writer + .execute( + "UPDATE message SET data = ?1 WHERE id='main-a'", + [json!({"role":"assistant","finish":"stop"}).to_string()], + ) + .unwrap(); + assert!( + matches!(&fixture.read("main").turns[0].items[0], ExternalSessionItem::Text { text } if text == "Done") + ); +} + +#[test] +fn replies_to_hidden_context_do_not_become_part_of_visible_history() { + let fixture = Fixture::new(true); + fixture.conversation("main"); + fixture.message( + "main", + "hidden-user", + 3, + json!({"role":"user","semantics":{"uiVisibility":"hidden"}}), + ); + fixture.part( + "main", + "hidden-user", + "hidden-input", + 3, + json!({"type":"text","text":"Internal context"}), + ); + fixture.message( + "main", + "hidden-reply", + 4, + json!({"role":"assistant","parentID":"hidden-user","finish":"stop"}), + ); + fixture.part( + "main", + "hidden-reply", + "hidden-output", + 4, + json!({"type":"text","text":"Internal reply"}), + ); + let document = fixture.read("main"); + assert_eq!(document.turns.len(), 1); + assert_eq!(document.turns[0].items.len(), 1); + assert!( + matches!(&document.turns[0].items[0], ExternalSessionItem::Text { text } if text == "Done") + ); +} + +#[test] +fn legacy_databases_without_sequence_and_future_optional_fields_remain_readable() { + let fixture = Fixture::new(false); + fixture.conversation("legacy"); + fixture + .writer + .execute_batch("ALTER TABLE session ADD COLUMN future_field TEXT;") + .unwrap(); + let document = fixture.read("legacy"); + assert_eq!(document.turns.len(), 1); + assert_eq!(document.turns[0].status, ExternalSessionStatus::Complete); + assert_eq!(document.candidate.format_version.as_deref(), Some("0.16.9")); +} + +#[test] +fn fingerprint_tracks_history_changes_but_not_unrelated_sessions() { + let fixture = Fixture::new(true); + fixture.conversation("main"); + let original = fixture.read("main").candidate.fingerprint; + fixture.conversation("other"); + assert_eq!(fixture.read("main").candidate.fingerprint, original); + fixture + .writer + .execute( + "UPDATE part SET data = ?1 WHERE id='main-p2'", + [json!({"type":"text","text":"Changed"}).to_string()], + ) + .unwrap(); + assert_ne!(fixture.read("main").candidate.fingerprint, original); +} + +#[test] +fn running_tools_remain_interrupted_and_malformed_records_fail_without_repairing_source() { + let fixture = Fixture::new(true); + fixture.conversation("main"); + fixture.part("main", "main-a", "running", 3, json!({"type":"tool","callID":"running","tool":"Bash","state":{"status":"running","input":{"command":"pending"}}})); + let document = fixture.read("main"); + assert_eq!(document.turns[0].status, ExternalSessionStatus::Interrupted); + assert!(matches!( + &document.turns[0].items[1], + ExternalSessionItem::Tool { + result: None, + success: None, + .. + } + )); + fixture + .writer + .execute("UPDATE part SET data='{' WHERE id='running'", []) + .unwrap(); + assert!(fixture + .reader + .read_candidate("zcode:main", ExternalSessionImportMode::Full) + .err() + .unwrap() + .contains("Malformed")); + let raw: String = fixture + .writer + .query_row("SELECT data FROM part WHERE id='running'", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(raw, "{"); +} + +#[test] +fn unsupported_schema_and_candidate_paths_do_not_return_empty_success() { + let fixture = Fixture::new(false); + fixture.conversation("main"); + assert!(fixture + .reader + .read_candidate("zcode:main' OR 1=1 --", ExternalSessionImportMode::Full) + .is_err()); + assert!(fixture + .reader + .read_candidate("codex:main", ExternalSessionImportMode::Full) + .is_err()); + fixture.writer.execute_batch("DROP TABLE part;").unwrap(); + assert!(fixture + .reader + .discover(0, 5, "") + .err() + .unwrap() + .contains("Unsupported")); +} + +#[test] +#[ignore = "Reads the installed Zcode session database without changing it"] +fn inspect_installed_zcode() { + let reader = ZcodeSessionReader::default(); + let catalog = reader.discover(0, 50, "").unwrap(); + assert!( + !catalog.candidates.is_empty(), + "No installed Zcode sessions found" + ); + let mut turns = 0; + let mut tools = 0; + for candidate in &catalog.candidates { + let document = reader + .read_candidate(&candidate.candidate_id, ExternalSessionImportMode::Full) + .unwrap(); + turns += document.turns.len(); + tools += document + .turns + .iter() + .flat_map(|turn| &turn.items) + .filter(|item| matches!(item, ExternalSessionItem::Tool { .. })) + .count(); + } + println!( + "Zcode read-only import: {} sessions, {turns} user turns, {tools} tool calls", + catalog.candidates.len() + ); +} diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index d9c4c05137..224cbebb72 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -375,6 +375,27 @@ local versus remote instruction composition: cargo test --locked -p openbitfun-core --no-default-features --features external-sources,git --lib instruction_ ``` +For external session indexing, immutable source views, viewed-version forks, +legacy materialization, workspace ownership and cancellation (including the full-text search build): + +```bash +cargo test -p openbitfun-core --no-default-features --features external-sources,git,product-search --lib external_session_import:: +cargo test -p openbitfun-core --no-default-features --features external-sources,git,product-search --lib agentic::persistence::manager::tests +cargo test -p openbitfun-core --no-default-features --features external-sources,git,product-search --lib session_fork_ +cargo test -p openbitfun-core --no-default-features --features external-sources,git,product-search --lib agentic::persistence::session_branch::tests +cargo test -p openbitfun-core --no-default-features --features external-sources,git,product-search --lib agentic::session::session_manager::tests::restore_session_ +``` + +Optional read-only Zcode-to-temporary-native-store verification uses the same +target with `inspect_installed_zcode_native_import -- --ignored --nocapture`. +It reads the installed source and writes only inside a temporary test directory. + +The same target also supports `inspect_installed_codex_history_repair -- --ignored --nocapture`. +Set `OPENBITFUN_TEST_NATIVE_SESSIONS_ROOT` and `OPENBITFUN_TEST_NATIVE_SESSION_ID` +to an existing answerless Codex import. It reads the installed native copy and +Codex source, exercises the repair in a temporary copy, and verifies the original +native history is unchanged. + Detached Dispatch controller, target query compatibility, and managed-baseline checks: ```bash diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 165b6e1dc7..523c1b14d5 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -123,6 +123,7 @@ openbitfun-dsh-adapter = { path = "../../adapters/dsh-adapter", optional = true openbitfun-opencode-plugin-host = { path = "../../adapters/opencode-plugin-host", optional = true } openbitfun-claude-code-adapter = { path = "../../adapters/claude-code-adapter", optional = true } openbitfun-codex-adapter = { path = "../../adapters/codex-adapter", optional = true } +openbitfun-zcode-adapter = { path = "../../adapters/zcode-adapter", optional = true } openbitfun-plugin-runtime-client = { path = "../../execution/plugin-runtime-client", optional = true } [features] @@ -278,6 +279,7 @@ external-sources = [ "dep:openbitfun-pi-adapter", "dep:openbitfun-claude-code-adapter", "dep:openbitfun-codex-adapter", + "dep:openbitfun-zcode-adapter", "dep:openbitfun-external-sources", "openbitfun-services-integrations/hook-import", "plugin-source", diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 8482a1405a..eda2bb0782 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -2379,6 +2379,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.ensure_runtime_ownership(&workspace.root_path, None, None)?; } let workspace = workspace_service.open_workspace_by_id(workspace_id).await?; + #[cfg(feature = "external-sources")] + for warning in + crate::external_session_import::auto_discovery::discover_workspace(&workspace, true) + .await + .warnings + { + log::warn!("External session discovery: {warning}"); + } if workspace.workspace_kind != WorkspaceKind::Remote { if let Err(error) = crate::service::snapshot::initialize_snapshot_manager_for_workspace( &workspace.id, @@ -6250,6 +6258,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet None => None, }; + #[cfg(feature = "external-sources")] + if let Some(restore) = requested_restore.as_ref() { + self.session_manager + .persistence_manager() + .assert_native_history_mutation(&restore.effective_storage_path, &session_id) + .await?; + } + // Get latest session, restoring from persistence on demand so every entry // point can use the same start_dialog_turn flow. A loaded session must keep // the same storage identity as this invocation. @@ -8663,6 +8679,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, undo: bool, ) -> OpenBitFunResult<(AgentSessionComposerUpdate, bool, usize)> { + #[cfg(feature = "external-sources")] + self.session_manager + .persistence_manager() + .assert_native_history_mutation(session_storage_path, session_id) + .await?; let workspace = self.local_revert_workspace(session_id).await?; let snapshot_manager = crate::service::snapshot::get_or_create_snapshot_manager(&workspace.id, None) @@ -8809,6 +8830,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Vec, Vec, )> { + #[cfg(feature = "external-sources")] + self.session_manager + .persistence_manager() + .assert_native_history_mutation(session_storage_path, session_id) + .await?; let workspace = self.local_revert_workspace(session_id).await?; let persistence = self.session_manager.persistence_manager(); let current = persistence @@ -9061,6 +9087,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet turn_id: Option<&str>, history: Option>, ) -> OpenBitFunResult<(Vec, Option)> { + #[cfg(feature = "external-sources")] + self.session_manager + .persistence_manager() + .assert_relay_history_supported(storage, session_id) + .await?; let _mutation = self .session_manager .acquire_session_mutation(session_id) diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 074ac23dc9..90512bfb90 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -2271,7 +2271,9 @@ impl DialogScheduler { session_id: &str, queued_turn: &QueuedTurn, ) -> Result { - let result = self.start_turn_inner(session_id, queued_turn).await; + // Startup crosses restore, history and context owners. Keep its state + // off the nested queue and background-result admission futures. + let result = Box::pin(self.start_turn_inner(session_id, queued_turn)).await; if let Ok(id) = &result { self.host_queue .lock() diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 32a719028a..cabc768c3f 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -2,6 +2,9 @@ //! //! Responsible for project-scoped session persistence. +#[cfg(feature = "external-sources")] +mod external_history; + use crate::agentic::core::{ sanitize_persisted_session_state, CompressionState, Message, MessageContent, PersistedSessionStateFile as StoredSessionStateFile, Session, SessionConfig, SessionState, @@ -1331,10 +1334,21 @@ impl PersistenceManager { return Ok(Vec::new()); } - self.session_metadata_store(workspace_path) + let metadata = self + .session_metadata_store(workspace_path) .list_metadata() .await - .map_err(Self::session_metadata_store_error) + .map_err(Self::session_metadata_store_error)?; + #[cfg(feature = "external-sources")] + let metadata = crate::external_session_import::deferred::merge_metadata( + &self.project_sessions_dir(workspace_path), + metadata, + ) + .await? + .into_iter() + .filter(|metadata| !metadata.should_hide_from_user_lists()) + .collect(); + Ok(metadata) } pub async fn session_metadata_by_ids( @@ -1342,10 +1356,31 @@ impl PersistenceManager { workspace_path: &Path, session_ids: &[String], ) -> OpenBitFunResult> { - self.session_metadata_store(workspace_path) + let mut metadata = self + .session_metadata_store(workspace_path) .metadata_by_ids(session_ids) .await - .map_err(Self::session_metadata_store_error) + .map_err(Self::session_metadata_store_error)?; + #[cfg(feature = "external-sources")] + crate::external_session_import::deferred::refresh_metadata_titles( + &self.project_sessions_dir(workspace_path), + &mut metadata, + ) + .await; + #[cfg(feature = "external-sources")] + for id in session_ids { + if !metadata.iter().any(|metadata| &metadata.session_id == id) { + if let Some(reference) = crate::external_session_import::deferred::metadata( + &self.project_sessions_dir(workspace_path), + id, + ) + .await? + { + metadata.push(reference); + } + } + } + Ok(metadata) } /// Lazily repair one legacy activity summary on a targeted navigation read. @@ -1462,6 +1497,33 @@ impl PersistenceManager { return Ok(empty_session_metadata_page()); } + #[cfg(feature = "external-sources")] + { + let root = self.project_sessions_dir(workspace_path); + let references = crate::external_session_import::deferred::has_references(&root).await; + if !matches!(references, Ok(false)) { + let native = self + .session_metadata_store(workspace_path) + .list_indexed_metadata() + .await + .map_err(Self::session_metadata_store_error)?; + if let Err(error) = references { + warn!("External session references could not be paged: {error}"); + let mut page = openbitfun_services_core::session::build_session_metadata_page( + native, cursor, limit, + ); + page.discovery_warnings.push("External session index could not be read; existing native sessions remain available".into()); + return Ok(page); + } + let metadata = + crate::external_session_import::deferred::merge_metadata(&root, native).await?; + return Ok( + openbitfun_services_core::session::build_session_metadata_page( + metadata, cursor, limit, + ), + ); + } + } self.session_metadata_store(workspace_path) .list_metadata_page(cursor, limit) .await @@ -1480,10 +1542,18 @@ impl PersistenceManager { return Ok(Vec::new()); } - self.session_metadata_store(workspace_path) + let metadata = self + .session_metadata_store(workspace_path) .list_metadata_including_internal() .await - .map_err(Self::session_metadata_store_error) + .map_err(Self::session_metadata_store_error)?; + #[cfg(feature = "external-sources")] + let metadata = crate::external_session_import::deferred::merge_metadata( + &self.project_sessions_dir(workspace_path), + metadata, + ) + .await?; + Ok(metadata) } pub async fn save_session_metadata( @@ -1521,6 +1591,15 @@ impl PersistenceManager { )); } } + #[cfg(feature = "external-sources")] + if crate::external_session_import::deferred::save_metadata( + &self.project_sessions_dir(workspace_path), + metadata, + ) + .await? + { + return Ok(()); + } self.session_metadata_store(workspace_path) .save_metadata(metadata) .await @@ -1599,6 +1678,24 @@ impl PersistenceManager { updated.session_name = session_name.to_string(); openbitfun_services_core::session::naming::clear_default_title(&mut updated, user_edited); updated.last_active_at = last_active_at; + if updated.session_name != original.session_name { + if let Some(custom) = updated + .custom_metadata + .as_mut() + .and_then(serde_json::Value::as_object_mut) + { + if custom + .get("importedFrom") + .and_then(serde_json::Value::as_str) + .is_some() + { + custom.insert( + "externalSessionTitleOverride".into(), + serde_json::json!(true), + ); + } + } + } let Err(write_error) = self .save_session_metadata_locked(workspace_path, &updated) @@ -1776,10 +1873,113 @@ impl PersistenceManager { session_id: &str, ) -> OpenBitFunResult> { Self::validate_session_id(session_id)?; - self.session_metadata_store(workspace_path) + let metadata = self + .session_metadata_store(workspace_path) .load_metadata(session_id) .await - .map_err(Self::session_metadata_store_error) + .map_err(Self::session_metadata_store_error)?; + #[cfg(feature = "external-sources")] + { + let root = self.project_sessions_dir(workspace_path); + return match metadata { + Some(mut metadata) => { + crate::external_session_import::deferred::refresh_metadata_titles( + &root, + std::slice::from_mut(&mut metadata), + ) + .await; + Ok(Some(metadata)) + } + None => crate::external_session_import::deferred::metadata(&root, session_id).await, + }; + } + #[cfg(not(feature = "external-sources"))] + Ok(metadata) + } + + /// History access promotes one lightweight external reference through the + /// same writer locks as rename/delete/continuation. Metadata-only reads do + /// not enter this path, so navigation never reads transcript bodies. + #[cfg(feature = "external-sources")] + async fn ensure_external_session_materialized( + &self, + workspace_path: &Path, + session_id: &str, + ) -> OpenBitFunResult<()> { + use crate::external_session_import::deferred; + let root = self.project_sessions_dir(workspace_path); + let native = self + .session_metadata_store(workspace_path) + .load_metadata(session_id) + .await + .map_err(Self::session_metadata_store_error)?; + if native + .as_ref() + .is_some_and(|metadata| !deferred::needs_legacy_history_repair(metadata)) + { + return Ok(()); + } + if native.is_none() { + let reference = deferred::metadata(&root, session_id).await?; + if reference.is_none() + || reference + .as_ref() + .is_some_and(deferred::tracking::is_followed) + { + return Ok(()); + } + } + if deferred::has_native_view(&root, session_id).await? { + return Ok(()); + } + let _writer = match self.lock_session_write_operation(workspace_path, session_id) { + Ok(writer) => writer, + Err(OpenBitFunError::SessionInUse { .. }) if native.is_some() => return Ok(()), + Err(error) => return Err(error), + }; + let lock = self + .get_session_persistence_lock(workspace_path, session_id) + .await; + // Native writes call history helpers while holding this lock. Skip the + // optional legacy repair in that case instead of re-entering the lock. + let _guard = if native.is_some() { + let Ok(guard) = lock.try_lock() else { + return Ok(()); + }; + guard + } else { + lock.lock().await + }; + let current = self + .session_metadata_store(workspace_path) + .load_metadata(session_id) + .await + .map_err(Self::session_metadata_store_error)?; + if current + .as_ref() + .is_some_and(|metadata| !deferred::needs_legacy_history_repair(metadata)) + { + return Ok(()); + } + // Legacy history decoding has a large future even when no repair is + // needed. Keep it off the nested native restore/admission futures. + Box::pin(deferred::materialize(&root, session_id)).await?; + if native.is_some() { + // The catalog is derived from Turn files and must include the + // repaired assistant content and any newly restored Turns. + match tokio::fs::remove_file(self.turn_catalog_path(workspace_path, session_id)).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => warn!( + "Failed to invalidate repaired Session Turn catalog: session_id={} error={}", + session_id, error + ), + } + #[cfg(feature = "product-search")] + self.invalidate_session_search(workspace_path, session_id) + .await; + } + Ok(()) } async fn load_stored_session_state( @@ -2043,6 +2243,8 @@ impl PersistenceManager { state: &SessionRevertState, ) -> OpenBitFunResult<()> { Self::validate_session_id(session_id)?; + #[cfg(feature = "external-sources")] + Box::pin(self.assert_native_history_mutation(workspace_path, session_id)).await?; let _session_write = self.lock_session_write_operation(workspace_path, session_id)?; self.ensure_runtime_for_write(workspace_path).await?; self.ensure_session_dir(workspace_path, session_id).await?; @@ -2612,6 +2814,8 @@ impl PersistenceManager { workspace_path: &Path, session: &Session, ) -> OpenBitFunResult<()> { + #[cfg(feature = "external-sources")] + Box::pin(self.assert_native_history_mutation(workspace_path, &session.session_id)).await?; Self::validate_session_id(&session.session_id)?; let _session_write = self.lock_session_write_operation(workspace_path, &session.session_id)?; @@ -2786,6 +2990,15 @@ impl PersistenceManager { session_id: &str, ) -> OpenBitFunResult<(Session, Vec, SessionTurnLoadTiming)> { Self::validate_session_id(session_id)?; + #[cfg(feature = "external-sources")] + if let Some((session, turns, _, timing)) = + Box::pin(self.load_external_history_view(workspace_path, session_id, None)).await? + { + return Ok((session, turns, timing)); + } + #[cfg(feature = "external-sources")] + self.ensure_external_session_materialized(workspace_path, session_id) + .await?; let request = SessionTurnLoadRequest { workspace_path: workspace_path.to_path_buf(), session_id: session_id.to_string(), @@ -2885,6 +3098,19 @@ impl PersistenceManager { tail_turn_count: usize, ) -> OpenBitFunResult<(Session, Vec, usize, SessionTurnLoadTiming)> { Self::validate_session_id(session_id)?; + #[cfg(feature = "external-sources")] + if let Some(view) = Box::pin(self.load_external_history_view( + workspace_path, + session_id, + Some(tail_turn_count), + )) + .await? + { + return Ok(view); + } + #[cfg(feature = "external-sources")] + self.ensure_external_session_materialized(workspace_path, session_id) + .await?; let request = SessionTurnLoadRequest { workspace_path: workspace_path.to_path_buf(), session_id: session_id.to_string(), @@ -3053,6 +3279,12 @@ impl PersistenceManager { .get_session_persistence_lock(workspace_path, session_id) .await; let _persistence_guard = persistence_lock.lock().await; + #[cfg(feature = "external-sources")] + crate::external_session_import::deferred::dismiss( + &self.project_sessions_dir(workspace_path), + session_id, + ) + .await?; #[cfg(feature = "product-search")] self.remove_session_from_search(workspace_path, session_id) .await; @@ -3159,6 +3391,27 @@ impl PersistenceManager { visible_total_turn_count: usize, ) -> OpenBitFunResult { Self::validate_session_id(session_id)?; + #[cfg(feature = "external-sources")] + if let Some(bundle) = Box::pin(self.external_history_for_loaded_turns( + workspace_path, + session_id, + loaded_turns, + )) + .await? + { + return Ok(build_turn_catalog( + session_id, + bundle + .turns + .iter() + .enumerate() + .map(|(ordinal, turn)| turn_catalog_entry(turn, ordinal)) + .collect(), + )); + } + #[cfg(feature = "external-sources")] + self.ensure_external_session_materialized(workspace_path, session_id) + .await?; let _session_write = self.lock_session_write_operation(workspace_path, session_id)?; let (physical_indices, can_persist_physical_projection) = match self @@ -3310,6 +3563,13 @@ impl PersistenceManager { request: &SessionTurnWindowRequest, ) -> OpenBitFunResult { Self::validate_session_id(&request.session_id)?; + #[cfg(feature = "external-sources")] + if let Some(window) = Box::pin(self.external_history_window(request)).await? { + return Ok(window); + } + #[cfg(feature = "external-sources")] + self.ensure_external_session_materialized(&request.workspace_path, &request.session_id) + .await?; let _session_write = self.lock_session_write_operation(&request.workspace_path, &request.session_id)?; let boundary_turn = self @@ -3585,6 +3845,8 @@ impl PersistenceManager { turn: &DialogTurnData, ) -> OpenBitFunResult<()> { Self::validate_session_id(&turn.session_id)?; + #[cfg(feature = "external-sources")] + Box::pin(self.assert_native_history_mutation(workspace_path, &turn.session_id)).await?; #[cfg(test)] { let mut fault = self @@ -3805,6 +4067,18 @@ impl PersistenceManager { turn_index: usize, ) -> OpenBitFunResult> { Self::validate_session_id(session_id)?; + #[cfg(feature = "external-sources")] + if let Some(bundle) = + Box::pin(self.external_history_snapshot(workspace_path, session_id, false)).await? + { + return Ok(bundle + .turns + .into_iter() + .find(|turn| turn.turn_index == turn_index)); + } + #[cfg(feature = "external-sources")] + self.ensure_external_session_materialized(workspace_path, session_id) + .await?; Ok(self .read_json_optional::(&self.turn_path( workspace_path, @@ -3899,6 +4173,15 @@ impl PersistenceManager { session_id: &str, ) -> OpenBitFunResult> { Self::validate_session_id(session_id)?; + #[cfg(feature = "external-sources")] + if let Some(bundle) = + Box::pin(self.external_history_snapshot(workspace_path, session_id, false)).await? + { + return Ok(bundle.turns); + } + #[cfg(feature = "external-sources")] + self.ensure_external_session_materialized(workspace_path, session_id) + .await?; let started_at = Instant::now(); let scan_started_at = Instant::now(); let indexed_paths = self @@ -4088,6 +4371,16 @@ impl PersistenceManager { if count == 0 { return Ok(Vec::new()); } + #[cfg(feature = "external-sources")] + if let Some(bundle) = + Box::pin(self.external_history_snapshot(workspace_path, session_id, false)).await? + { + let start = bundle.turns.len().saturating_sub(count); + return Ok(bundle.turns.into_iter().skip(start).collect()); + } + #[cfg(feature = "external-sources")] + self.ensure_external_session_materialized(workspace_path, session_id) + .await?; let started_at = Instant::now(); let metadata_started_at = Instant::now(); @@ -4179,6 +4472,8 @@ impl PersistenceManager { turn_index: usize, ) -> OpenBitFunResult<()> { Self::validate_session_id(session_id)?; + #[cfg(feature = "external-sources")] + Box::pin(self.assert_native_history_mutation(workspace_path, session_id)).await?; let _session_write = self.lock_session_write_operation(workspace_path, session_id)?; let persistence_lock = self .get_session_persistence_lock(workspace_path, session_id) diff --git a/src/crates/assembly/core/src/agentic/persistence/manager/external_history.rs b/src/crates/assembly/core/src/agentic/persistence/manager/external_history.rs new file mode 100644 index 0000000000..429ce203a6 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/persistence/manager/external_history.rs @@ -0,0 +1,255 @@ +use super::*; +use crate::agentic::persistence::session_branch::TransientSessionBranchSource; +use crate::external_session_import::deferred::{self, tracking}; +use openbitfun_services_core::session::OfflineSessionBundle; + +impl PersistenceManager { + pub(crate) async fn restore_external_session_reference( + &self, + workspace: &Path, + workspace_id: &str, + ecosystem_id: &str, + id: &str, + ) -> OpenBitFunResult<()> { + Self::validate_session_id(id)?; + let _session_write = self.lock_session_write_operation(workspace, id)?; + let persistence_lock = self.get_session_persistence_lock(workspace, id).await; + let _persistence_guard = persistence_lock.lock().await; + self.ensure_runtime_for_write(workspace).await?; + deferred::restore_reference( + &self.project_sessions_dir(workspace), + workspace_id, + ecosystem_id, + id, + ) + .await + } + + async fn has_independent_native_history( + &self, + workspace: &Path, + id: &str, + ) -> OpenBitFunResult { + let Some(native) = self + .session_metadata_store(workspace) + .load_metadata(id) + .await + .map_err(Self::session_metadata_store_error)? + else { + return Ok(false); + }; + if !native + .custom_metadata + .as_ref() + .is_some_and(|custom| custom["importedFrom"].is_string()) + { + return Ok(true); + } + match deferred::has_native_view(&self.project_sessions_dir(workspace), id).await { + Ok(followed) => Ok(!followed), + Err(error) => { + log::warn!( + "External history index could not be read; retaining native history: {error}" + ); + Ok(true) + } + } + } + pub(crate) async fn assert_relay_history_supported( + &self, + workspace: &Path, + id: &str, + ) -> OpenBitFunResult<()> { + if self + .load_session_metadata(workspace, id) + .await? + .as_ref() + .is_some_and(tracking::is_followed) + { + return Err(OpenBitFunError::Validation( + "Following external source history through Relay is not supported. Open it on its owning host or use a native OpenBitFun branch.".into(), + )); + } + Ok(()) + } + + pub(crate) async fn assert_native_history_mutation( + &self, + workspace: &Path, + id: &str, + ) -> OpenBitFunResult<()> { + if self + .load_session_metadata(workspace, id) + .await? + .as_ref() + .is_some_and(tracking::is_followed) + { + return Err(OpenBitFunError::Validation( + "This session follows an external source and is read-only. Fork the viewed history in OpenBitFun before continuing or editing it. This client must support session forks.".into(), + )); + } + Ok(()) + } + + pub(super) async fn external_history_snapshot( + &self, + workspace: &Path, + id: &str, + refresh: bool, + ) -> OpenBitFunResult> { + if self.has_independent_native_history(workspace, id).await? { + return Ok(None); + } + // Keep source decoding state out of every native persistence future. + // Native restores and undo/fork paths already nest several async owners. + Box::pin(tracking::snapshot( + &self.project_sessions_dir(workspace), + id, + refresh, + )) + .await + } + + pub(super) async fn external_history_for_loaded_turns( + &self, + workspace: &Path, + id: &str, + turns: &[DialogTurnData], + ) -> OpenBitFunResult> { + if self.has_independent_native_history(workspace, id).await? { + return Ok(None); + } + if let Some(turn) = turns.first() { + tracking::snapshot_at_turn(&self.project_sessions_dir(workspace), id, &turn.turn_id) + .await + } else { + self.external_history_snapshot(workspace, id, false).await + } + } + + pub(super) async fn load_external_history_view( + &self, + workspace: &Path, + id: &str, + tail: Option, + ) -> OpenBitFunResult, usize, SessionTurnLoadTiming)>> + { + let started = Instant::now(); + let Some(bundle) = self.external_history_snapshot(workspace, id, true).await? else { + return Ok(None); + }; + let total = bundle.turns.len(); + let session = + Self::build_session_from_persisted_parts(bundle.metadata, None, &bundle.turns).await?; + let turns = bundle + .turns + .into_iter() + .skip(tail.map_or(0, |count| total.saturating_sub(count))) + .collect::>(); + let timing = SessionTurnLoadTiming { + requested_tail_turn_count: tail, + loaded_turn_count: turns.len(), + total_turn_count: total, + total_duration_ms: elapsed_ms_u64(started), + ..Default::default() + }; + Ok(Some((session, turns, total, timing))) + } + + pub(super) async fn external_history_window( + &self, + request: &SessionTurnWindowRequest, + ) -> OpenBitFunResult> { + if self + .has_independent_native_history(&request.workspace_path, &request.session_id) + .await? + { + return Ok(None); + } + let root = self.project_sessions_dir(&request.workspace_path); + let bundle = if let Some(turn_id) = request.expected_turn_id.as_deref() { + tracking::snapshot_at_turn(&root, &request.session_id, turn_id).await? + } else { + tracking::snapshot(&root, &request.session_id, false).await? + }; + let Some(bundle) = bundle else { + return Ok(None); + }; + let catalog = build_turn_catalog( + &request.session_id, + bundle + .turns + .iter() + .enumerate() + .map(|(ordinal, turn)| turn_catalog_entry(turn, ordinal)) + .collect(), + ); + if request + .expected_catalog_revision + .as_deref() + .is_some_and(|revision| revision != catalog.revision) + { + return Ok(Some(SessionTurnWindowResponse::Stale { catalog })); + } + let Some(target) = bundle + .turns + .iter() + .position(|turn| turn.turn_index == request.target_storage_turn_index) + else { + return Ok(Some(SessionTurnWindowResponse::NotFound { catalog })); + }; + let start = target.saturating_sub(request.before.min(SESSION_TURN_WINDOW_MAX_BEFORE)); + let end = bundle.turns.len().min( + target.saturating_add( + request + .after + .clamp(1, SESSION_TURN_WINDOW_MAX_TARGET_AND_AFTER), + ), + ); + Ok(Some(SessionTurnWindowResponse::Ready { + catalog_revision: catalog.revision, + total_turn_count: catalog.total_turn_count, + start_ordinal: start, + end_ordinal_exclusive: end, + target_turn_id: bundle.turns[target].turn_id.clone(), + turns: bundle.turns[start..end].to_vec(), + })) + } + + pub(crate) async fn external_history_branch_source( + &self, + workspace: &Path, + id: &str, + turn_id: Option<&str>, + ) -> OpenBitFunResult> { + if self.has_independent_native_history(workspace, id).await? { + return Ok(None); + } + let root = self.project_sessions_dir(workspace); + if !deferred::metadata(&root, id) + .await? + .as_ref() + .is_some_and(tracking::is_followed) + { + return Ok(None); + } + let bundle = match turn_id { + Some(turn) => tracking::snapshot_at_turn(&root, id, turn).await?, + None => tracking::snapshot(&root, id, false).await?, + } + .ok_or_else(|| OpenBitFunError::NotFound("External source view is unavailable".into()))?; + let session = + Self::build_session_from_persisted_parts(bundle.metadata.clone(), None, &bundle.turns) + .await?; + Ok(Some(TransientSessionBranchSource { + session, + metadata: Some(bundle.metadata), + turns: bundle.turns, + context_snapshots: Vec::new(), + prompt_cache: None, + skill_agent_snapshots: Vec::new(), + baseline_override: None, + evidence_events: Vec::new(), + })) + } +} diff --git a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs index fa38f1d02e..ae8e5edf87 100644 --- a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs +++ b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs @@ -16,6 +16,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; /// Runtime-owned history; saving a fork never persists its temporary source. pub(crate) struct TransientSessionBranchSource { pub session: Session, + pub metadata: Option, pub turns: Vec, pub context_snapshots: Vec<(usize, Vec)>, pub prompt_cache: Option, @@ -39,8 +40,7 @@ impl PersistenceManager { workspace_path: &Path, request: &SessionBranchRequest, ) -> OpenBitFunResult { - self.branch_session_with_transient_source(workspace_path, request, None) - .await + Box::pin(self.branch_session_with_transient_source(workspace_path, request, None)).await } pub(crate) async fn branch_transient_session( @@ -49,7 +49,9 @@ impl PersistenceManager { request: &SessionBranchRequest, source: TransientSessionBranchSource, ) -> OpenBitFunResult { - self.branch_session_with_transient_source(workspace_path, request, Some(source)) + // Both native and external history forks copy complete persisted state. + // Do not embed that future into each nested runtime facade. + Box::pin(self.branch_session_with_transient_source(workspace_path, request, Some(source))) .await } @@ -74,8 +76,13 @@ impl PersistenceManager { } ( source.session.clone(), - self.build_session_metadata(workspace_path, &source.session, None) - .await, + match &source.metadata { + Some(metadata) => metadata.clone(), + None => { + self.build_session_metadata(workspace_path, &source.session, None) + .await + } + }, ) } else { let session = self @@ -309,19 +316,27 @@ impl PersistenceManager { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; - let branched_metadata = build_branched_session_metadata(BranchSessionMetadataFacts { - source_metadata: &source_metadata, - target_session_id: target_session_id.clone(), - target_session_name: target_session_name.clone(), - target_agent_type: target_agent_type.clone(), - source_session_id: &request.source_session_id, - source_turn_id: &request.source_turn_id, - source_turn_index, - boundary: request.boundary, - branched_turns: &branched_turns, - branch_lineage: &branch_lineage, - now_ms, - }); + #[allow(unused_mut)] + let mut branched_metadata = + build_branched_session_metadata(BranchSessionMetadataFacts { + source_metadata: &source_metadata, + target_session_id: target_session_id.clone(), + target_session_name: target_session_name.clone(), + target_agent_type: target_agent_type.clone(), + source_session_id: &request.source_session_id, + source_turn_id: &request.source_turn_id, + source_turn_index, + boundary: request.boundary, + branched_turns: &branched_turns, + branch_lineage: &branch_lineage, + now_ms, + }); + + #[cfg(feature = "external-sources")] + crate::external_session_import::deferred::tracking::branch_metadata( + &source_metadata, + &mut branched_metadata, + ); self.save_session_metadata(workspace_path, &branched_metadata) .await?; diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 486bed91e0..beb3ec2882 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -5778,13 +5778,16 @@ impl SessionManager { None }; let claimed = self.claim_session_storage_path(session_id, session_storage_path, true)?; - let result = self - .restore_session_with_turns_from_claimed_storage_path_internal( + // Runtime restore includes history decoding and context reconstruction. + // Keep that state on the heap across the nested fork/restore facades. + let result = Box::pin( + self.restore_session_with_turns_from_claimed_storage_path_internal( session_storage_path, session_id, include_internal, - ) - .await; + ), + ) + .await; if result.is_err() { self.release_failed_session_storage_path_claim( session_id, @@ -7921,6 +7924,7 @@ impl SessionManager { .collect(); Ok(TransientSessionBranchSource { session, + metadata: None, turns, context_snapshots, prompt_cache: self.prompt_cache_store.get_cache(session_id), diff --git a/src/crates/assembly/core/src/external_session_import.rs b/src/crates/assembly/core/src/external_session_import.rs new file mode 100644 index 0000000000..4882f54af9 --- /dev/null +++ b/src/crates/assembly/core/src/external_session_import.rs @@ -0,0 +1,691 @@ +//! Import and local continuation of external Agent sessions. + +pub mod auto_discovery; +pub(crate) mod deferred; +mod lifecycle; +pub use lifecycle::{list_removed_external_sessions, restore_removed_external_session}; + +use openbitfun_codex_adapter::session::CodexSessionReader; +use openbitfun_product_domains::external_sources::{ + ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceOperationResult, +}; +use openbitfun_product_domains::session_import::{ + ExternalSessionCandidate, ExternalSessionDocument, ExternalSessionImportMode, +}; +use openbitfun_runtime_ports::SessionStorePort; +use openbitfun_services_core::session::{ + DialogTurnData, ModelRoundData, OfflineSessionBundle, OfflineSessionImportStore, + SessionMemoryMode, SessionMetadata, SessionStatus, TextItemData, ThinkingItemData, + ToolCallData, ToolItemData, ToolResultData, TurnStatus, UserMessageData, +}; +use openbitfun_zcode_adapter::session::ZcodeSessionReader; +use serde_json::json; +use sha2::{Digest, Sha256}; +#[cfg(test)] +use std::time::Duration; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, LazyLock, Mutex, + }, + time::Instant, +}; +#[cfg(test)] +use uuid::Uuid; + +fn codex_reader() -> &'static CodexSessionReader { + static READER: LazyLock = LazyLock::new(CodexSessionReader::default); + &READER +} + +#[derive(Clone, Copy)] +enum ImportSource { + Codex, + Zcode, +} + +impl ImportSource { + fn from_ecosystem(id: &str) -> ExternalSourceOperationResult { + match id { + "codex" => Ok(Self::Codex), + "zcode" => Ok(Self::Zcode), + _ => Err(operation( + ExternalSourceOperationErrorCode::Unsupported, + "This external agent does not support session import", + false, + )), + } + } + + fn from_candidate(id: &str) -> ExternalSourceOperationResult { + Self::from_ecosystem( + id.split_once(':') + .map(|(ecosystem, _)| ecosystem) + .unwrap_or(""), + ) + } + + fn name(self) -> &'static str { + match self { + Self::Codex => "Codex", + Self::Zcode => "Zcode", + } + } + + fn format(self) -> &'static str { + match self { + Self::Codex => "codex-rollout-jsonl", + Self::Zcode => "zcode-session-sqlite", + } + } + + fn revision(self) -> &'static str { + match self { + Self::Codex => openbitfun_codex_adapter::session::SOURCE_REVISION, + Self::Zcode => openbitfun_zcode_adapter::session::SOURCE_REVISION, + } + } + + fn read(self, candidate: &str) -> Result { + match self { + Self::Codex => { + codex_reader().read_candidate(candidate, ExternalSessionImportMode::Full) + } + Self::Zcode => ZcodeSessionReader::default() + .read_candidate(candidate, ExternalSessionImportMode::Full), + } + } +} + +async fn import_storage( + workspace: &crate::service::workspace::WorkspaceInfo, +) -> ExternalSourceOperationResult<( + OfflineSessionImportStore, + crate::service::workspace::WorkspaceInfo, +)> { + let resolution = crate::agentic::session::CoreSessionStorePort::default() + .resolve_workspace_storage(&workspace.id) + .await + .map_err(|error| { + operation( + ExternalSourceOperationErrorCode::Unavailable, + error.to_string(), + true, + ) + })?; + let project_id = workspace.project_workspace_id().map_err(|error| { + operation( + ExternalSourceOperationErrorCode::InvalidRequest, + error, + false, + ) + })?; + let project = if project_id == workspace.id { + workspace.clone() + } else { + let service = + crate::service::workspace::get_global_workspace_service().ok_or_else(|| { + operation( + ExternalSourceOperationErrorCode::Unavailable, + "Workspace service is unavailable", + true, + ) + })?; + service + .require_workspace(project_id) + .await + .map_err(|error| { + operation( + ExternalSourceOperationErrorCode::Unavailable, + error.to_string(), + true, + ) + })? + }; + Ok(( + OfflineSessionImportStore::new(resolution.effective_storage_path), + project, + )) +} + +fn matching_workspace<'a>( + workspaces: &'a [crate::service::workspace::WorkspaceInfo], + source: &Path, +) -> Option<&'a crate::service::workspace::WorkspaceInfo> { + let source = normalized_path(source); + workspaces + .iter() + .filter(|workspace| { + workspace.workspace_kind != crate::service::workspace::WorkspaceKind::Remote + }) + .filter_map(|workspace| { + let root = normalized_path(&workspace.root_path); + let matches = source == root || source.starts_with(&(root.clone() + "/")); + matches.then_some((root.len(), workspace)) + }) + .max_by_key(|(length, _)| *length) + .map(|(_, workspace)| workspace) +} + +fn normalized_path(path: &Path) -> String { + let path = dunce::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .replace('\\', "/") + .trim_end_matches('/') + .to_string(); + if cfg!(windows) { + path.to_ascii_lowercase() + } else { + path + } +} + +fn convert_document( + session_id: &str, + workspace: &crate::service::workspace::WorkspaceInfo, + project: &crate::service::workspace::WorkspaceInfo, + agent_id: &str, + session_name: &str, + document: &ExternalSessionDocument, + include_tool_history: bool, + include_thinking: bool, +) -> ExternalSourceOperationResult { + // The source model is retained on the imported rounds and provenance + // metadata, while continuation uses the target host's configured primary + // model instead of assuming the source model exists locally. + let model_name = "primary".to_string(); + let mut metadata = SessionMetadata::new( + session_id.to_string(), + session_name.to_string(), + agent_id.to_string(), + model_name, + ); + metadata.workspace_id = Some(workspace.id.clone()); + metadata.workspace_path = Some(workspace.root_path.to_string_lossy().to_string()); + metadata.project_workspace_id = Some(project.id.clone()); + metadata.project_workspace_path = Some(project.root_path.to_string_lossy().to_string()); + metadata.memory_mode = SessionMemoryMode::Disabled; + metadata.status = SessionStatus::Completed; + let source = ImportSource::from_ecosystem(&document.candidate.ecosystem_id)?; + metadata.custom_metadata = Some(json!({ + "importedFrom": document.candidate.ecosystem_id, + "sourceSessionId": document.candidate.source_session_id, + "sourceSessionTitle": document.candidate.title, + "sourceFingerprint": document.candidate.fingerprint, + "sourceFormat": source.format(), + "sourceRevision": source.revision(), + "importWarningCodes": document.warnings.iter().map(|warning| &warning.code).collect::>(), + "sourceCwd": document.candidate.cwd, + "sourceModelProvider": document.candidate.model_provider, + "sourceModel": document.candidate.model_name, + "continuationMode": "openbitfun_local" + })); + + let mut turns = Vec::with_capacity(document.turns.len()); + for (turn_index, source) in document.turns.iter().enumerate() { + let turn_id = format!("{session_id}-turn-{turn_index}"); + let timestamp = if source.timestamp == 0 { + current_time_ms() + } else { + source.timestamp + }; + let mut turn = DialogTurnData::new_with_kind( + openbitfun_services_core::session::DialogTurnKind::UserDialog, + turn_id.clone(), + turn_index, + session_id.to_string(), + Some(agent_id.to_string()), + UserMessageData { + id: format!("{turn_id}-user"), + content: source.user_text.clone(), + timestamp, + metadata: Some( + json!({"importedFrom":document.candidate.ecosystem_id,"sourceTurnId":source.source_turn_id}), + ), + }, + ); + turn.timestamp = timestamp; + turn.start_time = timestamp; + let mut round = ModelRoundData { + id: format!("{turn_id}-round-0"), + turn_id: turn_id.clone(), + round_index: 0, + round_group_id: None, + timestamp, + text_items: Vec::new(), + tool_items: Vec::new(), + thinking_items: Vec::new(), + start_time: timestamp, + end_time: Some(timestamp), + duration_ms: Some(0), + provider_id: document.candidate.model_provider.clone(), + model_config_id: None, + effective_model_name: document.candidate.model_name.clone(), + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + attempt_diagnostics: Vec::new(), + failure_category: None, + token_details: None, + status: "completed".to_string(), + }; + let mut order = 0usize; + for item in &source.items { + match item { + openbitfun_product_domains::session_import::ExternalSessionItem::Text { text } => { + round.text_items.push(TextItemData { + id: format!("{turn_id}-text-{order}"), + content: text.clone(), + is_streaming: false, + timestamp, + is_markdown: true, + order_index: Some(order), + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + status: Some("completed".to_string()), + attempt_id: None, + attempt_index: None, + }); + } + openbitfun_product_domains::session_import::ExternalSessionItem::Thinking { + text, + } if include_thinking => { + round.thinking_items.push(ThinkingItemData { + id: format!("{turn_id}-thinking-{order}"), + content: text.clone(), + reasoning_kind: None, + is_streaming: false, + is_collapsed: true, + timestamp, + order_index: Some(order), + status: Some("completed".to_string()), + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + attempt_id: None, + attempt_index: None, + }); + } + openbitfun_product_domains::session_import::ExternalSessionItem::Thinking { + .. + } => {} + openbitfun_product_domains::session_import::ExternalSessionItem::Tool { + id, + name, + input, + result, + success, + } if include_tool_history => { + round.tool_items.push(ToolItemData { + id: format!("{turn_id}-tool-{order}"), + tool_name: name.clone(), + tool_call: ToolCallData { + input: input.clone(), + id: id.clone(), + }, + tool_result: result.clone().map(|result| ToolResultData { + result, + success: success.unwrap_or(true), + result_for_assistant: None, + image_attachments: None, + error: None, + duration_ms: None, + }), + ai_intent: None, + start_time: timestamp, + end_time: Some(timestamp), + duration_ms: Some(0), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: Some(order), + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_dialog_turn_id: None, + attempt_id: None, + attempt_index: None, + subagent_model_id: None, + subagent_model_display_name: None, + status: Some( + match success { + Some(true) => "completed", + Some(false) => "error", + None => "cancelled", + } + .to_string(), + ), + interruption_reason: None, + }); + } + openbitfun_product_domains::session_import::ExternalSessionItem::Tool { + .. + } => {} + } + order += 1; + } + turn.model_rounds.push(round); + turn.end_time = Some(timestamp); + turn.duration_ms = Some(0); + turn.has_final_response = Some(source.items.iter().any(|item| { + matches!( + item, + openbitfun_product_domains::session_import::ExternalSessionItem::Text { .. } + ) + })); + turn.status = match source.status { + openbitfun_product_domains::session_import::ExternalSessionStatus::Interrupted => { + TurnStatus::Cancelled + } + openbitfun_product_domains::session_import::ExternalSessionStatus::Corrupt => { + TurnStatus::Error + } + _ => TurnStatus::Completed, + }; + turns.push(turn); + } + metadata.turn_count = turns.len(); + metadata.message_count = turns.len() + + turns + .iter() + .map(|turn| { + turn.model_rounds + .iter() + .map(|round| round.text_items.len()) + .sum::() + }) + .sum::(); + metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); + metadata.last_active_at = turns + .last() + .and_then(|turn| turn.end_time) + .unwrap_or_else(current_time_ms); + metadata.last_finished_at = Some(metadata.last_active_at); + Ok(OfflineSessionBundle { metadata, turns }) +} + +fn current_time_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn operation( + code: ExternalSourceOperationErrorCode, + detail: impl Into, + retryable: bool, +) -> ExternalSourceOperationError { + ExternalSourceOperationError::new(code, detail, retryable) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::workspace::{WorkspaceInfo, WorkspaceKind}; + + #[test] + fn source_selection_never_falls_back_to_codex_for_an_unknown_ecosystem() { + assert!(matches!( + ImportSource::from_candidate("codex:source").unwrap(), + ImportSource::Codex + )); + assert!(matches!( + ImportSource::from_candidate("zcode:source").unwrap(), + ImportSource::Zcode + )); + assert!(ImportSource::from_candidate("unregistered:source").is_err()); + assert!(ImportSource::from_candidate("source").is_err()); + } + + #[tokio::test] + async fn zcode_history_round_trips_through_native_storage_with_its_own_provenance() { + use openbitfun_product_domains::session_import::{ + ExternalSessionItem, ExternalSessionStatus, ExternalSessionTurn, + }; + let root = tempfile::tempdir().unwrap(); + let workspace = workspace("source", root.path(), WorkspaceKind::Normal); + let document = ExternalSessionDocument { + candidate: serde_json::from_value(json!({ + "candidateId":"zcode:source", "ecosystemId":"zcode", "sourceSessionId":"source", + "fileSizeBytes":42, "status":"interrupted", "fingerprint":"sha256:fixture", + "modelName":"glm-source", "modelProvider":"zcode-source" + })) + .unwrap(), + turns: vec![ExternalSessionTurn { + source_turn_id: "source-user".into(), + timestamp: 1000, + user_text: "Continue this task".into(), + items: vec![ + ExternalSessionItem::Text { + text: "A saved answer".into(), + }, + ExternalSessionItem::Tool { + id: "failed".into(), + name: "Read".into(), + input: json!({"path":"missing"}), + result: Some(json!("Not found")), + success: Some(false), + }, + ExternalSessionItem::Tool { + id: "pending".into(), + name: "Bash".into(), + input: json!({"command":"pending"}), + result: None, + success: None, + }, + ], + source_event_count: 4, + status: ExternalSessionStatus::Interrupted, + }], + warnings: Vec::new(), + }; + let bundle = convert_document( + "zcode-import-fixture", + &workspace, + &workspace, + "primary", + "Imported task", + &document, + true, + false, + ) + .unwrap(); + let store = OfflineSessionImportStore::new(root.path().join("native-sessions")); + store.write_bundle_atomic(&bundle).await.unwrap(); + let saved = store + .load_bundle("zcode-import-fixture") + .await + .unwrap() + .unwrap(); + assert_eq!( + saved.metadata.custom_metadata.as_ref().unwrap()["importedFrom"], + "zcode" + ); + assert_eq!( + saved.metadata.custom_metadata.as_ref().unwrap()["sourceFormat"], + "zcode-session-sqlite" + ); + assert_eq!( + saved.turns[0].user_message.metadata.as_ref().unwrap()["importedFrom"], + "zcode" + ); + assert_eq!(saved.turns[0].user_message.content, "Continue this task"); + assert_eq!( + saved.turns[0].model_rounds[0] + .effective_model_name + .as_deref(), + Some("glm-source") + ); + assert_eq!( + saved.turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .unwrap() + .success, + false + ); + assert_eq!( + saved.turns[0].model_rounds[0].tool_items[1] + .status + .as_deref(), + Some("cancelled") + ); + } + + #[tokio::test] + #[ignore = "Reads installed Zcode history and writes only to a temporary native store"] + async fn inspect_installed_zcode_native_import() { + let reader = ZcodeSessionReader::default(); + let catalog = reader.discover(0, 5, "").unwrap(); + assert!( + !catalog.candidates.is_empty(), + "No installed Zcode sessions found" + ); + let root = tempfile::tempdir().unwrap(); + let store = OfflineSessionImportStore::new(root.path().join("sessions")); + for candidate in catalog.candidates { + let document = reader + .read_candidate(&candidate.candidate_id, ExternalSessionImportMode::Full) + .unwrap(); + let workspace = workspace("fixture-workspace", root.path(), WorkspaceKind::Normal); + let id = format!("zcode-import-{}", Uuid::new_v4()); + let bundle = convert_document( + &id, + &workspace, + &workspace, + "primary", + "Zcode verification", + &document, + true, + false, + ) + .unwrap(); + store.write_bundle_atomic(&bundle).await.unwrap(); + let saved = store.load_bundle(&id).await.unwrap().unwrap(); + assert_eq!( + serde_json::to_value(&saved.turns).unwrap(), + serde_json::to_value(&bundle.turns).unwrap() + ); + assert_eq!(saved.metadata.turn_count, document.turns.len()); + assert_eq!( + saved.metadata.custom_metadata.as_ref().unwrap()["importedFrom"], + "zcode" + ); + println!( + "Zcode native storage round trip: {} turns, {} tools", + saved.turns.len(), + saved.metadata.tool_call_count + ); + } + } + + fn workspace(id: &str, root: &Path, kind: WorkspaceKind) -> WorkspaceInfo { + serde_json::from_value(json!({ + "id": id, + "name": id, + "rootPath": root, + "workspaceType": "Other", + "workspaceKind": kind, + "status": "Inactive", + "languages": [], + "openedAt": "2026-01-01T00:00:00Z", + "lastAccessed": "2026-01-01T00:00:00Z", + "tags": [], + "metadata": {} + })) + .unwrap() + } + + #[test] + fn source_directory_selects_the_nearest_registered_workspace() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + let nested = project.join("nested"); + let source = nested.join("src"); + std::fs::create_dir_all(&source).unwrap(); + let workspaces = vec![ + workspace( + "unrelated", + &root.path().join("other"), + WorkspaceKind::Normal, + ), + workspace("project", &project, WorkspaceKind::Normal), + workspace("nested", &nested, WorkspaceKind::Normal), + ]; + + assert_eq!( + matching_workspace(&workspaces, &source).unwrap().id, + "nested" + ); + } + + #[test] + fn similarly_named_directory_does_not_reuse_an_unrelated_workspace() { + let root = tempfile::tempdir().unwrap(); + let workspaces = vec![workspace( + "project", + &root.path().join("project"), + WorkspaceKind::Normal, + )]; + + assert!(matching_workspace(&workspaces, &root.path().join("project-other")).is_none()); + } + + #[test] + fn remote_workspace_path_never_claims_a_local_codex_session() { + let root = tempfile::tempdir().unwrap(); + let workspaces = vec![workspace("remote", root.path(), WorkspaceKind::Remote)]; + + assert!(matching_workspace(&workspaces, root.path()).is_none()); + } + + #[test] + fn imported_worktree_session_preserves_execution_and_project_ownership() { + let root = tempfile::tempdir().unwrap(); + let project = workspace( + "project", + &root.path().join("project"), + WorkspaceKind::Normal, + ); + let execution = workspace( + "worktree", + &root.path().join("worktree"), + WorkspaceKind::Normal, + ); + let document = ExternalSessionDocument { + candidate: serde_json::from_value(json!({ + "candidateId": "source", "ecosystemId": "codex", "sourceSessionId": "source", + "fileSizeBytes": 1, "status": "complete", "fingerprint": "test" + })) + .unwrap(), + turns: Vec::new(), + warnings: Vec::new(), + }; + + let bundle = convert_document( + "imported", &execution, &project, "primary", "Imported", &document, true, false, + ) + .unwrap(); + + assert_eq!(bundle.metadata.workspace_id.as_deref(), Some("worktree")); + assert_eq!( + bundle.metadata.project_workspace_id.as_deref(), + Some("project") + ); + assert_eq!( + bundle.metadata.workspace_path, + Some(execution.root_path.to_string_lossy().into_owned()) + ); + assert_eq!( + bundle.metadata.project_workspace_path, + Some(project.root_path.to_string_lossy().into_owned()) + ); + } +} diff --git a/src/crates/assembly/core/src/external_session_import/auto_discovery.rs b/src/crates/assembly/core/src/external_session_import/auto_discovery.rs new file mode 100644 index 0000000000..dd6881e57b --- /dev/null +++ b/src/crates/assembly/core/src/external_session_import/auto_discovery.rs @@ -0,0 +1,705 @@ +//! Workspace-owned, opt-in external history discovery. Imported sessions use the +//! native persistence path; discovery never starts an external Agent runtime. + +use super::*; +use crate::service::workspace::{WorkspaceInfo, WorkspaceKind}; +use openbitfun_product_domains::session_import::ExternalSessionCatalog; + +#[derive(Default)] +struct DiscoveryState { + enabled_sources: Option>, + activation_revision: Option, + snapshot: DiscoverySnapshot, + cancelled: Arc, + worker: Arc>, +} + +#[derive(Clone, Default)] +pub struct DiscoverySnapshot { + pub pending: bool, + pub warnings: Vec, +} + +type DiscoveryLocks = HashMap>>; +static DISCOVERY: LazyLock> = LazyLock::new(|| Mutex::new(HashMap::new())); + +async fn enabled_sources() -> Result, String> { + let service = crate::service::config::get_global_config_service() + .await + .map_err(|error| error.to_string())?; + let settings: HashMap = service + .get_config(Some("app.external_session_discovery")) + .await + .map_err(|error| error.to_string())?; + Ok(["codex", "zcode"] + .into_iter() + .filter(|source| settings.get(*source) == Some(&true)) + .map(str::to_owned) + .collect()) +} + +/// Opening a workspace explicitly refreshes discovery. Metadata reads also run +/// this once after startup (including restored background workspace rows), and +/// after the user changes a source toggle. Concurrent readers share one scan. +pub async fn discover_workspace(workspace: &WorkspaceInfo, refresh: bool) -> DiscoverySnapshot { + let enabled = match enabled_sources().await { + Ok(enabled) => enabled, + Err(error) => { + log::warn!("Failed to read external session discovery preferences: {error}"); + return DiscoverySnapshot { + pending: false, + warnings: vec!["External session discovery preferences could not be read".into()], + }; + } + }; + if workspace.workspace_kind == WorkspaceKind::Remote { + return if enabled.is_empty() { + DiscoverySnapshot::default() + } else { + DiscoverySnapshot { + pending: false, + warnings: vec![ + "Automatic external session discovery is unavailable for SSH workspaces".into(), + ], + } + }; + } + let requested_at = Instant::now(); + let key = format!("{}:{}", workspace.id, normalized_path(&workspace.root_path)); + let workspace = workspace.clone(); + let sources = enabled.clone(); + let activation_revision = workspace.last_accessed.timestamp_nanos_opt(); + schedule_discovery( + key, + enabled, + refresh, + activation_revision, + move |cancelled| async move { + discover_enabled(&workspace, &sources, cancelled, requested_at).await + }, + ) + .await +} + +async fn schedule_discovery( + key: String, + enabled: Vec, + refresh: bool, + activation_revision: Option, + work: F, +) -> DiscoverySnapshot +where + F: FnOnce(Arc) -> Fut + Send + 'static, + Fut: std::future::Future> + Send + 'static, +{ + let lock = DISCOVERY + .lock() + .unwrap_or_else(|error| error.into_inner()) + .entry(key) + .or_default() + .clone(); + let mut state = lock.lock().await; + if enabled.is_empty() { + state.cancelled.store(true, Ordering::Release); + state.enabled_sources = Some(enabled); + state.activation_revision = activation_revision; + state.snapshot = DiscoverySnapshot::default(); + return state.snapshot.clone(); + } + if state.enabled_sources.as_ref() == Some(&enabled) + && (state.snapshot.pending + || (!refresh && state.activation_revision == activation_revision)) + { + return state.snapshot.clone(); + } + state.cancelled.store(true, Ordering::Release); + let cancelled = Arc::new(AtomicBool::new(false)); + state.cancelled = cancelled.clone(); + state.enabled_sources = Some(enabled); + state.activation_revision = activation_revision; + state.snapshot = DiscoverySnapshot { + pending: true, + warnings: Vec::new(), + }; + let worker = state.worker.clone(); + let completion_state = lock.clone(); + tokio::spawn(async move { + // Host-owned work outlives the initiating view or relay request. A + // restarted host resumes through persisted source identities. + let _worker = worker.lock().await; + if cancelled.load(Ordering::Acquire) { + return; + } + let warnings = work(cancelled.clone()).await; + let mut state = completion_state.lock().await; + if Arc::ptr_eq(&state.cancelled, &cancelled) && !cancelled.load(Ordering::Acquire) { + state.snapshot = DiscoverySnapshot { + pending: false, + warnings, + }; + } + }); + state.snapshot.clone() +} + +/// Resolve identities from this host's workspace registry. In particular, a +/// remote workspace's synthetic local storage directory is never treated as a +/// source directory and cannot cause a scan of controller-side history. +pub async fn discover_for_storage_path(storage_path: &Path) -> DiscoverySnapshot { + let Some(service) = crate::service::workspace::get_global_workspace_service() else { + return DiscoverySnapshot::default(); + }; + let workspaces = service.list_workspace_infos().await; + let runtime = + crate::service::WorkspaceRuntimeService::new(crate::infrastructure::get_path_manager_arc()); + let mut snapshot = DiscoverySnapshot::default(); + for workspace in workspaces_for_storage_path(storage_path, &workspaces, &runtime) { + let discovery = discover_workspace(workspace, false).await; + snapshot.pending |= discovery.pending; + snapshot.warnings.extend(discovery.warnings); + } + snapshot.warnings.sort(); + snapshot.warnings.dedup(); + snapshot +} + +fn workspaces_for_storage_path<'a>( + storage_path: &Path, + workspaces: &'a [WorkspaceInfo], + runtime: &crate::service::WorkspaceRuntimeService, +) -> Vec<&'a WorkspaceInfo> { + let path = normalized_path(storage_path); + // Session APIs resolve IDs to the owner's centralized sessions directory. + // Source cwd and project root are separate IO projections, never that directory. + let Some(project) = workspaces.iter().find(|workspace| { + workspace.workspace_kind != WorkspaceKind::Remote + && workspace.project_workspace_id().ok() == Some(workspace.id.as_str()) + && (normalized_path(&workspace.root_path) == path + || normalized_path( + &runtime + .context_for_local_workspace(&workspace.root_path) + .sessions_dir, + ) == path) + }) else { + return Vec::new(); + }; + workspaces + .iter() + .filter(|workspace| { + workspace.workspace_kind != WorkspaceKind::Remote + && workspace.project_workspace_id().ok() == Some(project.id.as_str()) + }) + .collect() +} + +async fn discover_enabled( + workspace: &WorkspaceInfo, + enabled: &[String], + cancelled: Arc, + requested_at: Instant, +) -> Vec { + let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() else { + return vec!["Runtime ownership is unavailable for external session discovery".into()]; + }; + if let Err(error) = + coordinator.ensure_workspace_runtime_ownership(&workspace.root_path, None, None) + { + log::warn!("External session discovery could not acquire workspace ownership: {error}"); + return vec![ + "Another runtime owns this workspace; automatic session import is unavailable".into(), + ]; + } + let Some(service) = crate::service::workspace::get_global_workspace_service() else { + return vec!["Workspace service is unavailable for external session discovery".into()]; + }; + let workspaces = service.list_workspace_infos().await; + let (store, project) = match import_storage(workspace).await { + Ok(storage) => storage, + Err(error) => return vec![error.detail], + }; + let mut warnings = Vec::new(); + for ecosystem in enabled { + if cancelled.load(Ordering::Acquire) { + break; + } + let source = match ImportSource::from_ecosystem(ecosystem) { + Ok(source) => source, + Err(_) => continue, + }; + let catalog = shared_catalog( + &SOURCE_CATALOGS, + ecosystem, + requested_at, + move || match source { + ImportSource::Codex => codex_reader().discover_all(), + ImportSource::Zcode => ZcodeSessionReader::default().discover_all(), + }, + ) + .await; + match catalog { + Ok(catalog) => { + warnings.extend( + register_catalog( + &store, + workspace, + &project, + &workspaces, + &catalog, + cancelled.as_ref(), + ) + .await, + ); + } + error => { + log::warn!("External session discovery failed for {ecosystem}: {error:?}"); + warnings.push(format!( + "{} session history could not be discovered", + source.name() + )); + } + } + } + warnings +} + +type SourceCatalogs = tokio::sync::Mutex)>>; +static SOURCE_CATALOGS: LazyLock = LazyLock::new(Default::default); + +async fn shared_catalog( + cache: &SourceCatalogs, + ecosystem: &str, + requested_at: Instant, + read: impl FnOnce() -> Result + Send + 'static, +) -> Result, String> { + // Serialize source scans across workspaces. Requests already queued when a + // scan finishes share that snapshot; a later workspace activation refreshes it. + let mut cache = cache.lock().await; + if let Some((completed_at, catalog)) = cache.get(ecosystem) { + if *completed_at >= requested_at { + return Ok(catalog.clone()); + } + } + let catalog = Arc::new( + tokio::task::spawn_blocking(read) + .await + .map_err(|error| error.to_string())??, + ); + cache.insert(ecosystem.into(), (Instant::now(), catalog.clone())); + Ok(catalog) +} + +async fn register_catalog( + store: &OfflineSessionImportStore, + workspace: &WorkspaceInfo, + project: &WorkspaceInfo, + workspaces: &[WorkspaceInfo], + catalog: &ExternalSessionCatalog, + cancelled: &AtomicBool, +) -> Vec { + if cancelled.load(Ordering::Acquire) { + return Vec::new(); + } + let mut warnings = Vec::new(); + if !catalog.diagnostics.is_empty() { + warnings.push(format!( + "Some {} source records could not be read", + catalog.ecosystem_id + )); + } + let candidates = catalog + .candidates + .iter() + .filter(|candidate| belongs_to_workspace(candidate, workspace, workspaces)) + .cloned() + .collect(); + if let Err(error) = + super::deferred::register(store.sessions_root(), workspace, project, candidates).await + { + log::warn!("External session index could not be updated: {error}"); + warnings.push("External session discovery index could not be updated".into()); + } + warnings +} + +pub(super) fn belongs_to_workspace( + candidate: &ExternalSessionCandidate, + workspace: &WorkspaceInfo, + workspaces: &[WorkspaceInfo], +) -> bool { + workspace.workspace_kind != WorkspaceKind::Remote + && candidate + .cwd + .as_deref() + .and_then(|cwd| matching_workspace(workspaces, Path::new(cwd))) + .is_some_and(|owner| owner.id == workspace.id) +} + +pub(super) fn source_identity(custom: Option<&serde_json::Value>) -> Option<(String, String)> { + let custom = custom?; + Some(( + custom.get("importedFrom")?.as_str()?.to_owned(), + custom.get("sourceSessionId")?.as_str()?.to_owned(), + )) +} + +pub(super) fn automatic_session_id(workspace: &str, ecosystem: &str, source: &str) -> String { + let mut hash = Sha256::new(); + for value in [workspace, ecosystem, source] { + hash.update(value.len().to_le_bytes()); + hash.update(value.as_bytes()); + } + format!("{ecosystem}-import-{:x}", hash.finalize()) +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + use openbitfun_product_domains::session_import::{ + ExternalSessionItem, ExternalSessionStatus, ExternalSessionTurn, + }; + + #[tokio::test] + async fn background_discovery_is_shared_and_survives_the_initiating_read() { + let key = Uuid::new_v4().to_string(); + let (release, wait) = tokio::sync::oneshot::channel(); + let first = schedule_discovery( + key.clone(), + vec!["codex".into()], + false, + None, + |_| async move { + wait.await.unwrap(); + vec!["fixture warning".into()] + }, + ) + .await; + assert!(first.pending); + // A metadata read finishes while the worker continues independently. + let second = schedule_discovery(key.clone(), vec!["codex".into()], true, None, |_| async { + panic!("An open during discovery must share the running worker") + }) + .await; + assert!(second.pending); + release.send(()).unwrap(); + let final_snapshot = tokio::time::timeout(Duration::from_secs(2), async { + loop { + tokio::task::yield_now().await; + let snapshot = + schedule_discovery(key.clone(), vec!["codex".into()], false, None, |_| async { + panic!("A metadata read must reuse completed discovery") + }) + .await; + if !snapshot.pending { + break snapshot; + } + } + }) + .await + .unwrap(); + assert_eq!(final_snapshot.warnings, vec!["fixture warning"]); + } + + #[tokio::test] + async fn disabling_discovery_cancels_pending_work_without_retaining_stale_issues() { + let key = Uuid::new_v4().to_string(); + let (started, start) = tokio::sync::oneshot::channel(); + let (stopped, stop) = tokio::sync::oneshot::channel(); + schedule_discovery( + key.clone(), + vec!["codex".into()], + false, + None, + |cancelled| async move { + started.send(()).unwrap(); + while !cancelled.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + stopped.send(()).unwrap(); + vec!["must not become visible".into()] + }, + ) + .await; + start.await.unwrap(); + let disabled = schedule_discovery(key.clone(), vec![], false, None, |_| async { + panic!("Disabled reader") + }) + .await; + assert!(!disabled.pending); + tokio::time::timeout(Duration::from_secs(2), stop) + .await + .unwrap() + .unwrap(); + let snapshot = schedule_discovery(key, vec![], false, None, |_| async { + panic!("Disabled reader") + }) + .await; + assert!(snapshot.warnings.is_empty()); + } + + pub(crate) fn workspace(id: &str, root: &Path, kind: WorkspaceKind) -> WorkspaceInfo { + serde_json::from_value(json!({ + "id": id, "name": id, "rootPath": root, "workspaceType": "Other", + "workspaceKind": kind, "status": "Inactive", "languages": [], + "openedAt": "2026-01-01T00:00:00Z", "lastAccessed": "2026-01-01T00:00:00Z", + "tags": [], "metadata": {} + })) + .unwrap() + } + + pub(crate) fn candidate(source: &str, id: &str, root: &Path) -> ExternalSessionCandidate { + serde_json::from_value(json!({ + "candidateId": format!("{source}:{id}"), "ecosystemId": source, "sourceSessionId": id, + "title": id, "cwd": root, "fileSizeBytes": 100, "status": "complete", "fingerprint": "new-version" + })).unwrap() + } + + pub(crate) fn document(candidate: ExternalSessionCandidate) -> ExternalSessionDocument { + ExternalSessionDocument { + candidate, + warnings: vec![], + turns: vec![ExternalSessionTurn { + source_turn_id: "source-turn".into(), + timestamp: 1234, + user_text: "Saved question".into(), + items: vec![ExternalSessionItem::Text { + text: "Saved answer".into(), + }], + source_event_count: 2, + status: ExternalSessionStatus::Complete, + }], + } + } + + fn catalog(source: &str, candidates: Vec) -> ExternalSessionCatalog { + ExternalSessionCatalog { + schema_version: 1, + ecosystem_id: source.into(), + source_revision: "test".into(), + candidates, + diagnostics: vec![], + next_cursor: None, + } + } + + #[tokio::test] + async fn centralized_metadata_storage_discovers_all_codex_history_beyond_existing_imports() { + let temp = tempfile::tempdir().unwrap(); + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).unwrap(); + let project = workspace("project", &project_root, WorkspaceKind::Normal); + let runtime = crate::service::WorkspaceRuntimeService::new(Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests(temp.path().join("user")), + )); + let storage = runtime + .context_for_local_workspace(&project_root) + .sessions_dir; + assert_ne!(normalized_path(&storage), normalized_path(&project_root)); + let workspaces = vec![project.clone()]; + let selected = workspaces_for_storage_path(&storage, &workspaces, &runtime); + assert_eq!( + selected.len(), + 1, + "The native metadata API passes centralized storage, not source cwd" + ); + let codex_home = temp.path().join("codex"); + std::fs::create_dir_all(codex_home.join("sessions")).unwrap(); + for index in 0..57 { + let events = [ + json!({"type":"session_meta","payload":{"id":index.to_string(),"cwd":project_root}}), + json!({"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Saved question"}]}}), + json!({"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Saved answer"}]}}), + ]; + std::fs::write( + codex_home.join(format!("sessions/rollout-{index}.jsonl")), + events + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); + } + let reader = Arc::new(CodexSessionReader::new(codex_home)); + let discovered = reader.discover_all().unwrap(); + let store = OfflineSessionImportStore::new(&storage); + // The old UI's one already-imported session must not hide the other 56. + let existing = reader + .read_candidate( + &discovered.candidates[0].candidate_id, + ExternalSessionImportMode::Full, + ) + .unwrap(); + let legacy = convert_document( + "previous-import", + &project, + &project, + "primary", + "Previously imported", + &existing, + true, + true, + ) + .unwrap(); + store.write_bundle_atomic(&legacy).await.unwrap(); + let warnings = register_catalog( + &store, + selected[0], + &project, + &workspaces, + &discovered, + &AtomicBool::new(false), + ) + .await; + assert!(warnings.is_empty(), "{warnings:?}"); + let native = store.list_metadata_including_internal().await.unwrap(); + assert_eq!( + native.len(), + 1, + "Startup must keep the one existing copy and write no transcript bodies" + ); + let merged = super::super::deferred::merge_metadata(&storage, native) + .await + .unwrap(); + let page = openbitfun_services_core::session::build_session_metadata_page(merged, None, 5); + assert_eq!( + page.total_top_level_count, 58, + "57 followed sources plus the existing independent import" + ); + assert!(page.has_more); + assert!(store + .load_bundle("previous-import") + .await + .unwrap() + .is_some()); + } + + #[test] + fn centralized_storage_matches_project_and_linked_worktree_but_never_a_remote_mirror() { + let temp = tempfile::tempdir().unwrap(); + let runtime = crate::service::WorkspaceRuntimeService::new(Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests(temp.path().join("user")), + )); + let project = workspace( + "project", + &temp.path().join("project"), + WorkspaceKind::Normal, + ); + let mut tree = workspace("tree", &temp.path().join("tree"), WorkspaceKind::Normal); + tree.worktree = Some(serde_json::from_value(json!({ + "mainWorkspaceId":"project", "path":tree.root_path, "mainRepoPath":project.root_path, "isMain":false + })).unwrap()); + let remote = workspace("remote", &project.root_path, WorkspaceKind::Remote); + let rows = vec![project.clone(), tree, remote]; + let storage = runtime + .context_for_local_workspace(&project.root_path) + .sessions_dir; + let ids = workspaces_for_storage_path(&storage, &rows, &runtime) + .into_iter() + .map(|w| w.id.as_str()) + .collect::>(); + assert_eq!(ids, vec!["project", "tree"]); + let mirror = runtime + .context_for_remote_workspace("host", "/project") + .sessions_dir; + assert!(workspaces_for_storage_path(&mirror, &rows, &runtime).is_empty()); + } + + #[tokio::test] + async fn reopening_a_workspace_refreshes_a_completed_scan_from_its_host_activation_revision() { + let key = Uuid::new_v4().to_string(); + for activation in [1, 2] { + assert!( + schedule_discovery( + key.clone(), + vec!["codex".into()], + false, + Some(activation), + |_| async { Vec::new() } + ) + .await + .pending + ); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + tokio::task::yield_now().await; + if !schedule_discovery( + key.clone(), + vec!["codex".into()], + false, + Some(activation), + |_| async { panic!("The same activation reuses its scan") }, + ) + .await + .pending + { + break; + } + } + }) + .await + .unwrap(); + } + } + + #[tokio::test] + async fn queued_workspaces_share_one_source_scan_and_later_activation_refreshes() { + let cache = SourceCatalogs::default(); + let requested = Instant::now(); + let first = shared_catalog(&cache, "codex", requested, || Ok(catalog("codex", vec![]))) + .await + .unwrap(); + let second = shared_catalog(&cache, "codex", requested, || { + panic!("Already scanned for this activation batch") + }) + .await + .unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + let third = shared_catalog(&cache, "codex", Instant::now(), || { + Ok(catalog("codex", vec![])) + }) + .await + .unwrap(); + assert!(!Arc::ptr_eq(&first, &third)); + } + + #[tokio::test] + async fn cancelled_registration_creates_no_index_or_session_files() { + let temp = tempfile::tempdir().unwrap(); + let workspace = workspace("project", temp.path(), WorkspaceKind::Normal); + let root = temp.path().join("native"); + let warnings = register_catalog( + &OfflineSessionImportStore::new(&root), + &workspace, + &workspace, + &[workspace.clone()], + &catalog("codex", vec![candidate("codex", "a", temp.path())]), + &AtomicBool::new(true), + ) + .await; + assert!(warnings.is_empty()); + assert!(!root.exists()); + } + + #[test] + fn ownership_uses_the_nearest_registered_local_workspace_and_rejects_remote_sources() { + let temp = tempfile::tempdir().unwrap(); + let root = workspace("root", temp.path(), WorkspaceKind::Normal); + let nested = workspace("nested", &temp.path().join("nested"), WorkspaceKind::Normal); + let remote = workspace("remote", temp.path(), WorkspaceKind::Remote); + let sources = vec![root.clone(), nested.clone(), remote.clone()]; + let nested_candidate = candidate("codex", "nested", &nested.root_path.join("src")); + assert!(belongs_to_workspace(&nested_candidate, &nested, &sources)); + assert!(!belongs_to_workspace(&nested_candidate, &root, &sources)); + assert!(!belongs_to_workspace( + &candidate("codex", "local", temp.path()), + &remote, + &sources + )); + assert!(!belongs_to_workspace( + &candidate("codex", "sibling", &temp.path().with_extension("sibling")), + &root, + &sources + )); + } +} diff --git a/src/crates/assembly/core/src/external_session_import/deferred.rs b/src/crates/assembly/core/src/external_session_import/deferred.rs new file mode 100644 index 0000000000..a9658398ea --- /dev/null +++ b/src/crates/assembly/core/src/external_session_import/deferred.rs @@ -0,0 +1,1927 @@ +//! Lightweight external history references. Only an explicit history read +//! reads a source snapshot; navigation never copies transcript bodies. + +mod legacy; +pub(crate) mod tracking; +pub(crate) use legacy::has_native_view; + +use super::*; +use crate::service::workspace::{WorkspaceInfo, WorkspaceKind}; +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use openbitfun_services_core::{json_store::JsonFileStore, session::SessionMetadataStore}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, HashSet}; +use std::time::SystemTime; + +const INDEX_FILE: &str = ".external-session-index.json"; +const PENDING_KEY: &str = "externalHistoryPending"; +const SOURCE_TITLE_KEY: &str = "sourceSessionTitle"; + +pub(crate) fn needs_legacy_history_repair(metadata: &SessionMetadata) -> bool { + let custom = metadata.custom_metadata.as_ref(); + custom.is_some_and(|custom| { + custom["importedFrom"] == "codex" && custom["sourceRevision"] == "codex-rollout-jsonl-v1" + }) && metadata.turn_count > 0 + && metadata.status != SessionStatus::Active + && metadata.message_count == metadata.turn_count + && metadata.tool_call_count == 0 +} + +fn can_repair_history(before: &OfflineSessionBundle, after: &OfflineSessionBundle) -> bool { + needs_legacy_history_repair(&before.metadata) + && before.turns.len() <= after.turns.len() + && after.metadata.message_count > after.metadata.turn_count + && before.turns.iter().zip(&after.turns).all(|(old, new)| { + old.turn_id == new.turn_id + && old.turn_index == new.turn_index + && old.status != TurnStatus::InProgress + && old.user_message.content.trim_end_matches(['\r', '\n']) + == new.user_message.content.trim_end_matches(['\r', '\n']) + && old.user_message.metadata.as_ref().is_some_and(|metadata| { + metadata["importedFrom"] == "codex" && metadata["sourceTurnId"].is_string() + }) + && old.model_rounds.len() == 1 + && old.model_rounds.iter().all(|round| { + round.status == "completed" + && round.text_items.is_empty() + && round.tool_items.is_empty() + && round.thinking_items.is_empty() + }) + }) +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Reference { + ecosystem_id: String, + candidate_id: String, + source_session_id: String, + metadata: SessionMetadata, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fallback_title: Option, + #[serde(default)] + dismissed: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Index { + #[serde(default = "schema_version")] + schema_version: u32, + #[serde(default)] + entries: BTreeMap, +} + +fn schema_version() -> u32 { + 1 +} +impl Default for Index { + fn default() -> Self { + Self { + schema_version: 1, + entries: BTreeMap::new(), + } + } +} + +type Stamp = (Option, u64); +type IndexCache = HashMap)>; +static CACHE: LazyLock> = LazyLock::new(Default::default); + +fn index_error(error: impl std::fmt::Display) -> OpenBitFunError { + OpenBitFunError::io(format!("External session index is unavailable: {error}")) +} + +async fn read_index(root: &Path) -> OpenBitFunResult> { + let path = root.join(INDEX_FILE); + let stat = match tokio::fs::metadata(&path).await { + Ok(stat) => stat, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Arc::new(Index::default())) + } + Err(error) => return Err(index_error(error)), + }; + let stamp = (stat.modified().ok(), stat.len()); + let mut cache = CACHE.lock().await; + if let Some((previous, index)) = cache.get(&path) { + if *previous == stamp { + return Ok(index.clone()); + } + } + let index: Index = JsonFileStore + .read_optional(&path) + .await + .map_err(index_error)? + .unwrap_or_default(); + if index.schema_version != 1 { + return Err(index_error( + "This external history index requires a newer OpenBitFun host", + )); + } + let index = Arc::new(index); + cache.insert(path, (stamp, index.clone())); + Ok(index) +} + +async fn update_index( + root: &Path, + update: impl FnOnce(&mut Index) -> (T, bool), +) -> OpenBitFunResult { + let path = root.join(INDEX_FILE); + let _lock = JsonFileStore + .acquire_cross_process_lock(&path) + .await + .map_err(index_error)?; + // Read under the cross-process writer lock; never overwrite a peer writer + // with an in-memory snapshot or repair an unreadable record by discarding it. + let mut index: Index = JsonFileStore + .read_optional(&path) + .await + .map_err(index_error)? + .unwrap_or_default(); + if index.schema_version != 1 { + return Err(index_error( + "This external history index requires a newer OpenBitFun host", + )); + } + let (result, changed) = update(&mut index); + if changed { + JsonFileStore + .write_atomic_strict(&path, &index) + .await + .map_err(index_error)?; + CACHE.lock().await.remove(&path); + SessionMetadataStore::notify_catalog_changed(); + } + Ok(result) +} + +pub(crate) fn is_deferred(metadata: &SessionMetadata) -> bool { + metadata + .custom_metadata + .as_ref() + .and_then(|value| value.get(PENDING_KEY)) + .and_then(serde_json::Value::as_bool) + == Some(true) +} + +fn apply_source_title( + metadata: &mut SessionMetadata, + source_title: Option<&str>, + fallback_title: Option<&str>, +) -> bool { + let Some(title) = source_title.filter(|title| !title.trim().is_empty()) else { + return false; + }; + if metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get("externalSessionTitleOverride")) + .and_then(Value::as_bool) + == Some(true) + { + return false; + } + let previous = metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get(SOURCE_TITLE_KEY)) + .and_then(Value::as_str); + let unchanged = if let Some(previous) = previous { + metadata.session_name == previous + } else { + // Older imports used either the adapter's exact prompt preview or its + // empty-title default. Do not infer ownership from arbitrary prefixes. + metadata.session_name == title + || fallback_title == Some(metadata.session_name.as_str()) + || metadata.session_name == "Imported session" + || metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get("importedFrom")) + .and_then(Value::as_str) + .and_then(|source| ImportSource::from_ecosystem(source).ok()) + .is_some_and(|source| { + metadata.session_name == format!("Imported {} Session", source.name()) + }) + }; + if !unchanged || (metadata.session_name == title && previous == Some(title)) { + return false; + } + metadata.session_name = title.into(); + if let Some(custom) = metadata + .custom_metadata + .get_or_insert_with(|| json!({})) + .as_object_mut() + { + custom.insert(SOURCE_TITLE_KEY.into(), json!(title)); + } + true +} + +fn project_titles(index: &Index, metadata: &mut [SessionMetadata]) { + let sources = index + .entries + .values() + .filter(|entry| !entry.dismissed) + .map(|entry| { + ( + ( + entry.metadata.workspace_id.as_deref(), + entry.ecosystem_id.as_str(), + entry.source_session_id.as_str(), + ), + entry, + ) + }) + .collect::>(); + for metadata in metadata { + let source = super::auto_discovery::source_identity(metadata.custom_metadata.as_ref()); + let entry = index + .entries + .get(&metadata.session_id) + .filter(|entry| !entry.dismissed) + .or_else(|| { + source.as_ref().and_then(|(ecosystem, source_id)| { + sources + .get(&( + metadata.workspace_id.as_deref(), + ecosystem.as_str(), + source_id.as_str(), + )) + .copied() + }) + }); + if let Some(entry) = entry { + apply_source_title( + metadata, + entry.source_title.as_deref(), + entry.fallback_title.as_deref(), + ); + } + } +} + +/// Metadata reads project corrected source names without rewriting every native +/// Session on startup. A later ordinary save retains the title and its baseline. +pub(crate) async fn refresh_metadata_titles(root: &Path, metadata: &mut [SessionMetadata]) { + for entry in metadata.iter_mut() { + tracking::project_native_metadata(entry); + } + match read_index(root).await { + Ok(index) => { + project_titles(&index, metadata); + legacy::project_metadata(&index, metadata); + } + Err(error) => log::warn!("External session names could not be refreshed: {error}"), + } +} + +pub(crate) async fn register( + root: &Path, + workspace: &WorkspaceInfo, + project: &WorkspaceInfo, + candidates: Vec, +) -> OpenBitFunResult { + if candidates.is_empty() { + return Ok(0); + } + // This index read does not open hundreds of existing metadata/Turn files. + let native = SessionMetadataStore::new(root) + .list_indexed_metadata() + .await + .map_err(index_error)?; + let native_by_source = native + .iter() + .filter(|metadata| metadata.workspace_id.as_deref() == Some(&workspace.id)) + .filter_map(|metadata| { + super::auto_discovery::source_identity(metadata.custom_metadata.as_ref()) + .map(|identity| (identity, metadata)) + }) + .collect::>(); + let native_ids = native + .iter() + .map(|metadata| metadata.session_id.as_str()) + .collect::>(); + update_index(root, |index| { + let mut added = 0; + let mut changed = false; + let mut known = index + .entries + .iter() + .filter(|(id, entry)| { + entry.metadata.workspace_id.as_deref() == Some(&workspace.id) + && (entry.dismissed || legacy::follows_native(&entry.metadata) || (!native_ids.contains(id.as_str()) + && (is_deferred(&entry.metadata) || tracking::is_followed(&entry.metadata)))) + }) + .map(|(id, entry)| { + ( + (entry.ecosystem_id.clone(), entry.source_session_id.clone()), + id.clone(), + ) + }) + .collect::>(); + for (id, entry) in &index.entries { + if entry.metadata.workspace_id.as_deref() == Some(&workspace.id) + && !entry.dismissed && !legacy::is_alias(&entry.metadata) + && !native_ids.contains(id.as_str()) && tracking::is_followed(&entry.metadata) { + known.insert((entry.ecosystem_id.clone(), entry.source_session_id.clone()), id.clone()); + } + } + for candidate in candidates { + let identity = ( + candidate.ecosystem_id.clone(), + candidate.source_session_id.clone(), + ); + let automatic_id = super::auto_discovery::automatic_session_id( + &workspace.id, &identity.0, &identity.1, + ); + let existing = native.iter().find(|metadata| metadata.session_id == automatic_id) + .or_else(|| native_by_source.get(&identity).copied()); + if let Some(native) = existing { + if !index.entries.contains_key(&native.session_id) { + index.entries.insert(native.session_id.clone(), Reference { + ecosystem_id: identity.0.clone(), candidate_id: candidate.candidate_id.clone(), + source_session_id: identity.1.clone(), metadata: native.clone(), + source_title: candidate.title.clone(), fallback_title: candidate.fallback_title.clone(), dismissed: false, + }); + changed = true; + } + } + if let Some(native) = existing.filter(|native| native.session_id == automatic_id) { + changed |= legacy::prepare_reference(index, native, &candidate); + if index.entries.get(&automatic_id).is_some_and(|entry| legacy::follows_native(&entry.metadata)) { + known.insert(identity.clone(), automatic_id.clone()); + } + } + // Automatic legacy imports retain their ID; opening validates the + // old copy before treating it as a cache of the source. + let mut id = if existing.is_some() { format!("external-{automatic_id}") } else { automatic_id.clone() }; + let mut generation = 2; + while native_ids.contains(id.as_str()) { + id = format!("external-{automatic_id}-{generation}"); + generation += 1; + } + // A persisted reference owns user edits and deletion intent. A scan + // cannot undo rename/archive/delete or a completed native import. + if let Some(known_id) = known.get(&identity) { + if let Some(entry) = index + .entries + .get_mut(known_id) + .filter(|entry| !entry.dismissed) + { + if !tracking::is_followed(&entry.metadata) && existing.is_none() { + entry.metadata.custom_metadata.get_or_insert_with(|| json!({}))["externalHistoryMode"] = json!("follow"); + changed = true; + } + if tracking::is_followed(&entry.metadata) { + if let Some(last_active_at) = candidate.updated_at.or(candidate.created_at) { + if entry.metadata.last_active_at != last_active_at { + entry.metadata.last_active_at = last_active_at; + changed = true; + } + } + let custom = entry.metadata.custom_metadata.get_or_insert_with(|| json!({})); + let observed = json!(candidate.fingerprint); + if custom["externalObservedFingerprint"] != observed { + custom["externalObservedFingerprint"] = observed; + changed = true; + } + if custom["externalContentVersion"].is_null() { + let incomplete = json!(candidate.status != openbitfun_product_domains::session_import::ExternalSessionStatus::Complete); + if custom["externalSourceIncomplete"] != incomplete { + custom["externalSourceIncomplete"] = incomplete; + changed = true; + } + } + } + // Codex may archive a rollout under a different filename. + // Refresh its locator while keeping all user metadata. + if entry.candidate_id != candidate.candidate_id { + entry.candidate_id = candidate.candidate_id; + changed = true; + } + // An unavailable optional title store must not erase a name + // obtained from the source on an earlier successful scan. + if candidate.title.is_some() + && (candidate.title_is_source_name || entry.source_title.is_none()) + && entry.source_title != candidate.title + { + entry.source_title = candidate.title; + changed = true; + } + if entry.fallback_title != candidate.fallback_title { + entry.fallback_title = candidate.fallback_title; + changed = true; + } + if is_deferred(&entry.metadata) { + changed |= apply_source_title( + &mut entry.metadata, + entry.source_title.as_deref(), + entry.fallback_title.as_deref(), + ); + } + } + continue; + } + known.insert(identity.clone(), id.clone()); + let metadata = { + let mut metadata = SessionMetadata::new( + id.clone(), + candidate + .title + .clone() + .unwrap_or_else(|| "Imported session".into()), + "primary".into(), + "primary".into(), + ); + metadata.workspace_id = Some(workspace.id.clone()); + metadata.workspace_path = Some(workspace.root_path.to_string_lossy().into_owned()); + metadata.project_workspace_id = Some(project.id.clone()); + metadata.project_workspace_path = + Some(project.root_path.to_string_lossy().into_owned()); + metadata.created_at = candidate.created_at.or(candidate.updated_at).unwrap_or(0); + metadata.last_active_at = + candidate.updated_at.or(candidate.created_at).unwrap_or(0); + metadata.memory_mode = SessionMemoryMode::Disabled; + metadata.status = SessionStatus::Completed; + metadata.custom_metadata = Some(json!({ + "importedFrom": identity.0, "sourceSessionId": identity.1, + "sourceFingerprint": candidate.fingerprint, "sourceCwd": candidate.cwd, + "sourceSessionTitle": candidate.title, + "externalHistoryPending": true, "continuationMode": "fork_before_continue", + "externalHistoryMode": "follow", "externalObservedFingerprint": candidate.fingerprint, + "externalSourceIncomplete": candidate.status != openbitfun_product_domains::session_import::ExternalSessionStatus::Complete + })); + metadata + }; + index.entries.insert( + id, + Reference { + ecosystem_id: identity.0, + candidate_id: candidate.candidate_id, + source_session_id: identity.1, + metadata, + source_title: candidate.title, + fallback_title: candidate.fallback_title, + dismissed: false, + }, + ); + added += 1; + changed = true; + } + (added, changed) + }) + .await +} + +pub(crate) async fn merge_metadata( + root: &Path, + mut native: Vec, +) -> OpenBitFunResult> { + for metadata in &mut native { + tracking::project_native_metadata(metadata); + } + let index = match read_index(root).await { + Ok(index) => index, + Err(error) => { + // The page endpoint exposes a discovery warning. An unreadable + // optional sidecar must not hide independently persisted Sessions. + log::warn!("External session references could not be listed: {error}"); + return Ok(native); + } + }; + project_titles(&index, &mut native); + legacy::project_metadata(&index, &mut native); + let identities = native + .iter() + .filter_map(|metadata| { + super::auto_discovery::source_identity(metadata.custom_metadata.as_ref()) + .map(|source| (metadata.workspace_id.clone(), source)) + }) + .collect::>(); + let ids = native + .iter() + .map(|metadata| metadata.session_id.clone()) + .collect::>(); + native.extend( + index + .entries + .values() + .filter(|entry| { + !entry.dismissed + && !legacy::is_alias(&entry.metadata) + && (is_deferred(&entry.metadata) || tracking::is_followed(&entry.metadata)) + && !ids.contains(&entry.metadata.session_id) + && (tracking::is_followed(&entry.metadata) + || !identities.contains(&( + entry.metadata.workspace_id.clone(), + (entry.ecosystem_id.clone(), entry.source_session_id.clone()), + ))) + }) + .map(|entry| entry.metadata.clone()), + ); + native.sort_by(|a, b| { + b.last_active_at + .cmp(&a.last_active_at) + .then(a.session_id.cmp(&b.session_id)) + }); + Ok(native) +} + +pub(crate) async fn has_references(root: &Path) -> OpenBitFunResult { + Ok(read_index(root).await?.entries.values().any(|entry| { + !entry.dismissed + && (is_deferred(&entry.metadata) + || tracking::is_followed(&entry.metadata) + || entry.source_title.is_some()) + })) +} + +pub(crate) async fn metadata(root: &Path, id: &str) -> OpenBitFunResult> { + Ok(read_index(root) + .await? + .entries + .get(id) + .filter(|entry| { + !entry.dismissed + && (is_deferred(&entry.metadata) || tracking::is_followed(&entry.metadata)) + }) + .map(|entry| entry.metadata.clone())) +} + +pub(crate) async fn save_metadata( + root: &Path, + metadata: &SessionMetadata, +) -> OpenBitFunResult { + if (!is_deferred(metadata) && !tracking::is_followed(metadata)) + || (!legacy::follows_native(metadata) + && tokio::fs::try_exists(root.join(&metadata.session_id).join("metadata.json")) + .await + .map_err(index_error)?) + { + return Ok(false); + } + update_index(root, |index| { + let Some(entry) = index + .entries + .get_mut(&metadata.session_id) + .filter(|entry| !entry.dismissed) + else { + return (false, false); + }; + let renamed = entry.metadata.session_name != metadata.session_name; + let mut next = metadata.clone(); + if tracking::is_followed(&entry.metadata) { + next.turn_count = entry.metadata.turn_count; + next.message_count = entry.metadata.message_count; + next.tool_call_count = entry.metadata.tool_call_count; + next.last_turn = entry.metadata.last_turn.clone(); + next.last_finished_at = entry.metadata.last_finished_at; + let custom = next.custom_metadata.get_or_insert_with(|| json!({})); + if let Some(current) = entry + .metadata + .custom_metadata + .as_ref() + .and_then(Value::as_object) + { + for (key, value) in current { + if key.starts_with("external") + || key.starts_with("source") + || key == "importedFrom" + || key == "continuationMode" + { + custom[key] = value.clone(); + } + } + } + } + entry.metadata = next; + if renamed { + if let Some(custom) = entry + .metadata + .custom_metadata + .as_mut() + .and_then(Value::as_object_mut) + { + custom.insert("externalSessionTitleOverride".into(), json!(true)); + } + } + (true, true) + }) + .await +} + +pub(crate) async fn dismiss(root: &Path, id: &str) -> OpenBitFunResult<()> { + let native = SessionMetadataStore::new(root) + .load_metadata(id) + .await + .map_err(index_error)?; + let identity = native.as_ref().and_then(|metadata| { + super::auto_discovery::source_identity(metadata.custom_metadata.as_ref()) + }); + if identity.is_none() && !read_index(root).await?.entries.contains_key(id) { + return Ok(()); + } + update_index(root, |index| { + let mut changed = false; + let deleting_source = index + .entries + .get(id) + .is_some_and(|entry| legacy::follows_native(&entry.metadata)); + if let (Some(metadata), Some((ecosystem_id, source_session_id))) = + (native.as_ref(), identity.as_ref()) + { + if !index.entries.contains_key(id) { + index.entries.insert( + id.into(), + Reference { + ecosystem_id: ecosystem_id.clone(), + candidate_id: String::new(), + source_session_id: source_session_id.clone(), + metadata: metadata.clone(), + source_title: None, + fallback_title: None, + dismissed: true, + }, + ); + changed = true; + } + if let Some(entry) = index.entries.get_mut(id).filter(|_| !deleting_source) { + // An older writer may have materialized a followed reference. + // Its deletion must only dismiss that now-independent copy. + entry.metadata = metadata.clone(); + tracking::project_native_metadata(&mut entry.metadata); + changed = true; + } + } + for (entry_id, entry) in &mut index.entries { + let same_source = identity.as_ref().is_some_and(|(ecosystem, source)| { + ecosystem == &entry.ecosystem_id + && source == &entry.source_session_id + && native.as_ref().is_some_and(|metadata| { + metadata.workspace_id == entry.metadata.workspace_id + }) + }); + if (entry_id == id + || (same_source && (deleting_source || !tracking::is_followed(&entry.metadata)))) + && !entry.dismissed + { + entry.dismissed = true; + changed = true; + } + } + ((), changed) + }) + .await +} + +pub(crate) async fn removed_metadata( + root: &Path, + workspace_id: &str, + ecosystem_id: &str, +) -> OpenBitFunResult> { + Ok(read_index(root) + .await? + .entries + .values() + .filter(|entry| { + entry.dismissed + && !legacy::is_alias(&entry.metadata) + && (is_deferred(&entry.metadata) || tracking::is_followed(&entry.metadata)) + && entry.metadata.workspace_id.as_deref() == Some(workspace_id) + && entry.ecosystem_id == ecosystem_id + }) + .map(|entry| entry.metadata.clone()) + .collect()) +} + +pub(crate) async fn restore_reference( + root: &Path, + workspace_id: &str, + ecosystem_id: &str, + id: &str, +) -> OpenBitFunResult<()> { + update_index(root, |index| { + let Some(entry) = index.entries.get_mut(id).filter(|entry| { + entry.metadata.workspace_id.as_deref() == Some(workspace_id) + && entry.ecosystem_id == ecosystem_id + && !legacy::is_alias(&entry.metadata) + && (is_deferred(&entry.metadata) || tracking::is_followed(&entry.metadata)) + }) else { + return ( + Err(OpenBitFunError::NotFound( + "Removed source session was not found in this workspace".into(), + )), + false, + ); + }; + if !entry.dismissed { + // A retry after a successful restore must not undo a later archive. + return (Ok(()), false); + } + entry.dismissed = false; + entry.metadata.status = SessionStatus::Active; + let custom = entry + .metadata + .custom_metadata + .get_or_insert_with(|| json!({})); + custom[PENDING_KEY] = json!(true); + custom["externalHistoryMode"] = json!("follow"); + custom["continuationMode"] = json!("fork_before_continue"); + // Legacy links can refer to an alias of this same source. Preserve those + // identities and snapshots without creating another navigation row. + for alias in index.entries.values_mut().filter(|entry| { + entry.metadata.workspace_id.as_deref() == Some(workspace_id) + && entry.ecosystem_id == ecosystem_id + && entry + .metadata + .custom_metadata + .as_ref() + .is_some_and(|custom| custom["externalHistoryAliasOf"] == id) + }) { + alias.dismissed = false; + } + (Ok(()), true) + }) + .await? +} + +/// Caller holds the existing Session write and persistence locks. An unopened +/// reference is kept on failure, so source loss never masquerades as an empty +/// native conversation and can be retried after the source is restored. +pub(crate) async fn materialize(root: &Path, id: &str) -> OpenBitFunResult<()> { + // A prepared repair survives process interruption and source loss. + if OfflineSessionImportStore::new(root) + .resume_bundle_repair(id) + .await + .map_err(index_error)? + { + return Ok(()); + } + let Some(entry) = read_index(root) + .await? + .entries + .get(id) + .filter(|entry| !entry.dismissed) + .cloned() + else { + return Ok(()); + }; + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| index_error("Workspace service is unavailable"))?; + let workspace_id = entry + .metadata + .workspace_id + .as_deref() + .ok_or_else(|| index_error("External history has no workspace owner"))?; + let workspace = service + .require_workspace(workspace_id) + .await + .map_err(index_error)?; + if workspace.workspace_kind == WorkspaceKind::Remote { + return Err(index_error( + "External history is unavailable for SSH workspaces", + )); + } + let (store, project) = import_storage(&workspace) + .await + .map_err(|error| index_error(error.detail))?; + if normalized_path(store.sessions_root()) != normalized_path(root) { + return Err(index_error( + "External history belongs to a different storage owner", + )); + } + let workspaces = service.list_workspace_infos().await; + materialize_reference(root, entry, workspace, project, workspaces, |source, id| { + source.read(id) + }) + .await +} + +async fn materialize_reference( + root: &Path, + entry: Reference, + workspace: WorkspaceInfo, + project: WorkspaceInfo, + workspaces: Vec, + read: impl FnOnce(ImportSource, &str) -> Result + Send + 'static, +) -> OpenBitFunResult<()> { + // Kept only for legacy fixed imports and the guarded v1 history repair. + // New source views never publish a native Session merely by being opened. + if tracking::is_followed(&entry.metadata) { + return Box::pin(tracking::refresh_reference( + root, entry, workspace, project, workspaces, read, + )) + .await; + } + let store = OfflineSessionImportStore::new(root); + let before = store + .load_bundle(&entry.metadata.session_id) + .await + .map_err(index_error)?; + if before + .as_ref() + .is_some_and(|bundle| !needs_legacy_history_repair(&bundle.metadata)) + { + return Ok(()); + } + let source = ImportSource::from_ecosystem(&entry.ecosystem_id) + .map_err(|error| index_error(error.detail))?; + let candidate_id = entry.candidate_id.clone(); + let document = match tokio::task::spawn_blocking(move || read(source, &candidate_id)).await.map_err(index_error)? { + Ok(document) => document, + Err(error) if before.is_some() => { + log::warn!("Original Codex history is unavailable for legacy repair; keeping the existing native copy: session_id={} error={error}", entry.metadata.session_id); + return Ok(()); + }, + Err(error) => return Err(index_error(format!("The original {} history could not be read. Restore the source file or database and retry. {error}", entry.ecosystem_id))), + }; + if document.candidate.ecosystem_id != entry.ecosystem_id + || document.candidate.source_session_id != entry.source_session_id + || !super::auto_discovery::belongs_to_workspace( + &document.candidate, + &workspace, + &workspaces, + ) + { + return Err(index_error( + "External history source identity or workspace changed; no native copy was created", + )); + } + let mut metadata = before + .as_ref() + .map(|bundle| bundle.metadata.clone()) + .unwrap_or(entry.metadata); + if document.candidate.title_is_source_name { + apply_source_title( + &mut metadata, + document.candidate.title.as_deref(), + document.candidate.fallback_title.as_deref(), + ); + } + let mut bundle = convert_document( + &metadata.session_id, + &workspace, + &project, + &metadata.agent_type, + &metadata.session_name, + &document, + true, + true, + ) + .map_err(|error| index_error(error.detail))?; + if let Some(before) = &before { + if !can_repair_history(before, &bundle) { + return Ok(()); + } + // Preserve the exact imported prompt (including its original whitespace) + // and message identity while filling only the missing history. + for (old, new) in before.turns.iter().zip(&mut bundle.turns) { + new.user_message = old.user_message.clone(); + } + } + metadata.turn_count = bundle.metadata.turn_count; + metadata.message_count = bundle.metadata.message_count; + metadata.tool_call_count = bundle.metadata.tool_call_count; + metadata.last_finished_at = bundle.metadata.last_finished_at; + if before.is_some() { + metadata.last_turn = None; + } + let custom = metadata.custom_metadata.get_or_insert_with(|| json!({})); + if let Some(custom) = custom.as_object_mut() { + custom.remove(PENDING_KEY); + if let Some(mut source_custom) = bundle + .metadata + .custom_metadata + .and_then(|value| value.as_object().cloned()) + { + if !document.candidate.title_is_source_name && custom.contains_key(SOURCE_TITLE_KEY) { + source_custom.remove(SOURCE_TITLE_KEY); + } + custom.extend(source_custom); + } + } + bundle.metadata = metadata; + if let Some(before) = &before { + store + .repair_bundle(before, &bundle) + .await + .map_err(index_error)?; + } else { + store + .write_bundle_atomic(&bundle) + .await + .map_err(index_error)?; + } + // Keep the reference for deletion intent and compatibility; native metadata + // always wins. A crash before this update still resolves to the native copy. + update_index(root, |index| { + if let Some(entry) = index.entries.get_mut(&bundle.metadata.session_id) { + entry.metadata = bundle.metadata; + if document.candidate.title_is_source_name { + entry.source_title = document.candidate.title.clone(); + entry.fallback_title = document.candidate.fallback_title.clone(); + } + ((), true) + } else { + ((), false) + } + }) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::auto_discovery::tests::{candidate, document}; + use super::*; + use crate::agentic::persistence::PersistenceManager; + use crate::infrastructure::PathManager; + + fn legacy_answerless_bundle(id: &str, workspace: &WorkspaceInfo) -> OfflineSessionBundle { + let mut original = document(candidate("codex", id, &workspace.root_path)); + original.turns[0].items.clear(); + original.turns[0].user_text.push('\n'); + let mut bundle = convert_document( + id, + workspace, + workspace, + "primary", + "My saved name", + &original, + true, + true, + ) + .unwrap(); + bundle.metadata.custom_metadata.as_mut().unwrap()["sourceRevision"] = + json!("codex-rollout-jsonl-v1"); + bundle + } + + #[tokio::test] + #[ignore = "Read-only installed history verification; writes only to a temporary copy"] + async fn inspect_installed_codex_history_repair() { + let input_root = PathBuf::from( + std::env::var_os("OPENBITFUN_TEST_NATIVE_SESSIONS_ROOT") + .expect("Set OPENBITFUN_TEST_NATIVE_SESSIONS_ROOT"), + ); + let id = std::env::var("OPENBITFUN_TEST_NATIVE_SESSION_ID") + .expect("Set OPENBITFUN_TEST_NATIVE_SESSION_ID"); + let source_store = OfflineSessionImportStore::new(&input_root); + let before = source_store.load_bundle(&id).await.unwrap().unwrap(); + assert!(needs_legacy_history_repair(&before.metadata)); + let source_id = before.metadata.custom_metadata.as_ref().unwrap()["sourceSessionId"] + .as_str() + .unwrap(); + let candidate = codex_reader() + .discover_all() + .unwrap() + .candidates + .into_iter() + .find(|candidate| candidate.source_session_id == source_id) + .unwrap(); + let (_temp, mut workspace, root, _manager) = fixture(); + workspace.id = before.metadata.workspace_id.clone().unwrap(); + workspace.root_path = PathBuf::from(before.metadata.workspace_path.as_ref().unwrap()); + let entry = Reference { + ecosystem_id: "codex".into(), + candidate_id: candidate.candidate_id, + source_session_id: source_id.into(), + metadata: before.metadata.clone(), + source_title: candidate.title, + fallback_title: candidate.fallback_title, + dismissed: false, + }; + let store = OfflineSessionImportStore::new(&root); + store.write_bundle_atomic(&before).await.unwrap(); + materialize_reference( + &root, + entry, + workspace.clone(), + workspace.clone(), + vec![workspace], + |source, id| source.read(id), + ) + .await + .unwrap(); + let after = store.load_bundle(&id).await.unwrap().unwrap(); + assert!(!needs_legacy_history_repair(&after.metadata)); + assert!(after.metadata.message_count > after.metadata.turn_count); + assert_eq!( + after.turns[0].user_message.content, + before.turns[0].user_message.content + ); + assert_eq!( + serde_json::to_value(source_store.load_bundle(&id).await.unwrap().unwrap()).unwrap(), + serde_json::to_value(before).unwrap() + ); + println!("Installed history repair in temporary copy: {} turns, {} assistant text items, {} tools; original native copy unchanged", after.metadata.turn_count, after.metadata.message_count - after.metadata.turn_count, after.metadata.tool_call_count); + } + + #[tokio::test] + async fn legacy_answerless_import_is_repaired_on_open_with_native_metadata_preserved() { + let (_temp, workspace, root, manager) = fixture(); + let before = legacy_answerless_bundle("old", &workspace); + let store = OfflineSessionImportStore::new(&root); + store.write_bundle_atomic(&before).await.unwrap(); + let source = candidate("codex", "old", &workspace.root_path); + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(); + let entry = read_index(&root).await.unwrap().entries["old"].clone(); + let mut complete = document(source); + let mut second = complete.turns[0].clone(); + second.source_turn_id = "second".into(); + second.user_text = "Follow-up question".into(); + complete.turns.push(second); + materialize_reference( + &root, + entry, + workspace.clone(), + workspace.clone(), + vec![workspace.clone()], + move |_, _| Ok(complete), + ) + .await + .unwrap(); + let after = store.load_bundle("old").await.unwrap().unwrap(); + assert_eq!(after.turns.len(), 2); + assert_eq!(after.metadata.message_count, 4); + assert_eq!(after.metadata.session_name, before.metadata.session_name); + assert_eq!( + after.metadata.last_active_at, + before.metadata.last_active_at + ); + assert_eq!(after.metadata.workspace_id, before.metadata.workspace_id); + assert_eq!( + after.turns[0].user_message.content, + before.turns[0].user_message.content + ); + assert_eq!( + after.turns[0].model_rounds[0].text_items[0].content, + "Saved answer" + ); + assert!(!needs_legacy_history_repair(&after.metadata)); + assert!(root.join("old/.history-repair.json").is_file()); + // Public history reads and a new manager both see the restored turns. + let (_, turns) = manager.load_session_with_turns(&root, "old").await.unwrap(); + assert_eq!(turns.len(), 2); + let restarted = PersistenceManager::new(Arc::new(PathManager::with_user_root_for_tests( + _temp.path().join("user"), + ))) + .unwrap(); + assert_eq!( + restarted + .load_session_turns(&root, "old") + .await + .unwrap() + .len(), + 2 + ); + } + + #[tokio::test] + async fn legacy_repair_preserves_local_prompt_edits_continuations_and_source_loss() { + for scenario in ["edited", "continued", "missing", "active", "running"] { + let (_temp, workspace, root, _manager) = fixture(); + let mut before = legacy_answerless_bundle(scenario, &workspace); + if scenario == "edited" { + before.turns[0].user_message.content = "My locally edited question".into(); + } else if scenario == "active" { + before.metadata.status = SessionStatus::Active; + } else if scenario == "running" { + before.turns[0].status = TurnStatus::InProgress; + } else if scenario == "continued" { + let mut local = before.turns[0].clone(); + local.turn_id = format!("{scenario}-turn-1"); + local.turn_index = 1; + local.user_message.metadata = None; + before.turns.push(local); + before.metadata.turn_count = 2; + before.metadata.message_count = 2; + } + let store = OfflineSessionImportStore::new(&root); + store.write_bundle_atomic(&before).await.unwrap(); + let source = candidate("codex", scenario, &workspace.root_path); + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(); + let entry = read_index(&root).await.unwrap().entries[scenario].clone(); + let mut complete = document(source); + complete.turns.push(complete.turns[0].clone()); + materialize_reference( + &root, + entry, + workspace.clone(), + workspace.clone(), + vec![workspace], + move |_, _| { + if scenario == "missing" { + Err("Source is offline".into()) + } else { + Ok(complete) + } + }, + ) + .await + .unwrap(); + let after = store.load_bundle(scenario).await.unwrap().unwrap(); + assert_eq!( + serde_json::to_value(after).unwrap(), + serde_json::to_value(before).unwrap() + ); + assert!(!root.join(scenario).join(".history-repair.json").exists()); + } + } + + #[tokio::test] + async fn persistence_recovers_a_prepared_legacy_repair_without_the_source() { + use openbitfun_services_core::session::StoredDialogTurnFile; + let (_temp, workspace, root, manager) = fixture(); + let before = legacy_answerless_bundle("recover", &workspace); + let mut source = document(candidate("codex", "recover", &workspace.root_path)); + source.turns.push(source.turns[0].clone()); + let after = convert_document( + "recover", + &workspace, + &workspace, + "primary", + "My saved name", + &source, + true, + true, + ) + .unwrap(); + let store = OfflineSessionImportStore::new(&root); + store.write_bundle_atomic(&before).await.unwrap(); + JsonFileStore + .write_atomic_strict( + &root.join("recover/.history-repair.json"), + &json!({"schema_version":1,"before":before,"after":after}), + ) + .await + .unwrap(); + // The old metadata still declares one turn; an interrupted writer has + // already appended the second turn. Ordinary bundle loading now fails. + JsonFileStore + .write_atomic_strict( + &root.join("recover/turns/turn-0001.json"), + &StoredDialogTurnFile::new(after.turns[1].clone()), + ) + .await + .unwrap(); + assert!(store.load_bundle("recover").await.is_err()); + let (_, tail, total) = manager + .load_session_with_tail_turns(&root, "recover", 1) + .await + .unwrap(); + assert_eq!(total, 2); + assert_eq!(tail.len(), 1); + assert_eq!( + tail[0].model_rounds[0].text_items[0].content, + "Saved answer" + ); + assert!(!needs_legacy_history_repair( + &store + .load_bundle("recover") + .await + .unwrap() + .unwrap() + .metadata + )); + } + + #[tokio::test] + async fn native_write_does_not_reenter_the_optional_legacy_repair_lock() { + let (_temp, workspace, root, manager) = fixture(); + let before = legacy_answerless_bundle("writing", &workspace); + let store = OfflineSessionImportStore::new(&root); + store.write_bundle_atomic(&before).await.unwrap(); + let mut turn = before.turns[0].clone(); + turn.user_message.content = "A local edit".into(); + turn.user_message.metadata = None; + tokio::time::timeout( + Duration::from_secs(5), + manager.save_dialog_turn(&root, &turn), + ) + .await + .expect("Native persistence must not deadlock during legacy repair") + .unwrap(); + assert_eq!( + store.load_bundle("writing").await.unwrap().unwrap().turns[0] + .user_message + .content, + "A local edit" + ); + } + + pub(super) fn fixture() -> ( + tempfile::TempDir, + WorkspaceInfo, + PathBuf, + PersistenceManager, + ) { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + let project = dunce::canonicalize(project).unwrap(); + let workspace = + crate::service::workspace::legacy_compat::register_local_fixture_blocking(&project); + let paths = Arc::new(PathManager::with_user_root_for_tests( + temp.path().join("user"), + )); + let root = paths.project_sessions_dir(&project); + ( + temp, + workspace, + root, + PersistenceManager::new(paths).unwrap(), + ) + } + + async fn entry(root: &Path) -> Reference { + read_index(root) + .await + .unwrap() + .entries + .values() + .next() + .unwrap() + .clone() + } + + #[tokio::test] + async fn source_names_refresh_legacy_references_without_unfolding_history_or_losing_local_names( + ) { + let (_temp, workspace, root, manager) = fixture(); + let mut source = candidate("codex", "one", &workspace.root_path); + source.title = Some("Prompt preview".into()); + source.fallback_title = source.title.clone(); + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(); + let id = entry(&root).await.metadata.session_id; + update_index(&root, |index| { + let entry = index.entries.get_mut(&id).unwrap(); + entry.source_title = None; + entry.fallback_title = None; + entry + .metadata + .custom_metadata + .as_mut() + .unwrap() + .as_object_mut() + .unwrap() + .remove(SOURCE_TITLE_KEY); + ((), true) + }) + .await + .unwrap(); + source.title = Some("Original Agent name".into()); + source.title_is_source_name = true; + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(); + assert_eq!( + manager + .load_session_metadata(&root, &id) + .await + .unwrap() + .unwrap() + .session_name, + "Original Agent name" + ); + assert!(!root.join(&id).exists()); + let mut unavailable = source.clone(); + unavailable.title = unavailable.fallback_title.clone(); + unavailable.title_is_source_name = false; + register(&root, &workspace, &workspace, vec![unavailable]) + .await + .unwrap(); + assert_eq!( + manager + .load_session_metadata(&root, &id) + .await + .unwrap() + .unwrap() + .session_name, + "Original Agent name" + ); + manager + .update_session_title_metadata(&root, &id, "My local name", 4000, true) + .await + .unwrap(); + // Even a source name temporarily matching the local edit must not take + // ownership of that user edit on a later scan. + for name in ["My local name", "Another source name"] { + source.title = Some(name.into()); + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(); + } + let restored = manager + .load_session_metadata(&root, &id) + .await + .unwrap() + .unwrap(); + assert_eq!(restored.session_name, "My local name"); + assert_eq!(restored.last_active_at, 4000); + assert!(!root.join(id).exists()); + } + + #[tokio::test] + async fn legacy_native_names_are_corrected_on_reads_without_rewriting_history_or_user_edits() { + let (_temp, workspace, root, manager) = fixture(); + let mut candidates = Vec::new(); + let mut original_metadata = Vec::new(); + for (id, title) in [ + ("default", "Prompt preview"), + ("renamed", "My existing name"), + ] { + let mut source = candidate("codex", id, &workspace.root_path); + source.title = Some("Prompt preview".into()); + source.fallback_title = source.title.clone(); + let mut bundle = convert_document( + id, + &workspace, + &workspace, + "primary", + title, + &document(source.clone()), + true, + true, + ) + .unwrap(); + bundle + .metadata + .custom_metadata + .as_mut() + .unwrap() + .as_object_mut() + .unwrap() + .remove(SOURCE_TITLE_KEY); + OfflineSessionImportStore::new(&root) + .write_bundle_atomic(&bundle) + .await + .unwrap(); + original_metadata.push(( + id, + std::fs::read(root.join(id).join("metadata.json")).unwrap(), + )); + source.title = Some("Saved source name".into()); + source.title_is_source_name = true; + candidates.push(source); + } + register(&root, &workspace, &workspace, candidates.clone()) + .await + .unwrap(); + let page = manager + .list_session_metadata_page(&root, None, 5) + .await + .unwrap(); + assert_eq!( + page.sessions + .iter() + .find(|session| session.session_id == "default") + .unwrap() + .session_name, + "Saved source name" + ); + assert_eq!( + page.sessions + .iter() + .find(|session| session.session_id == "renamed") + .unwrap() + .session_name, + "My existing name" + ); + assert_eq!( + manager + .session_metadata_by_ids(&root, &["default".into()]) + .await + .unwrap()[0] + .session_name, + "Saved source name" + ); + assert_eq!( + manager + .load_session_metadata(&root, "default") + .await + .unwrap() + .unwrap() + .session_name, + "Saved source name" + ); + for (id, before) in original_metadata { + assert_eq!( + std::fs::read(root.join(id).join("metadata.json")).unwrap(), + before + ); + assert_eq!( + manager.load_session_turns(&root, id).await.unwrap()[0] + .user_message + .content, + "Saved question" + ); + } + manager + .update_session_title_metadata(&root, "default", "My chosen name", 5000, true) + .await + .unwrap(); + for source in &mut candidates { + source.title = Some("Source renamed later".into()); + } + register(&root, &workspace, &workspace, candidates) + .await + .unwrap(); + assert_eq!( + manager + .load_session_metadata(&root, "default") + .await + .unwrap() + .unwrap() + .session_name, + "My chosen name" + ); + } + + #[tokio::test] + async fn first_open_uses_a_source_name_changed_after_indexing() { + let (_temp, workspace, root, manager) = fixture(); + let mut source = candidate("codex", "one", &workspace.root_path); + source.title_is_source_name = true; + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(); + let reference = entry(&root).await; + let id = reference.metadata.session_id.clone(); + source.title = Some("Name saved since discovery".into()); + materialize_reference( + &root, + reference, + workspace.clone(), + workspace.clone(), + vec![workspace], + move |_, _| Ok(document(source)), + ) + .await + .unwrap(); + assert_eq!( + manager + .load_session_metadata(&root, &id) + .await + .unwrap() + .unwrap() + .session_name, + "Name saved since discovery" + ); + assert!(SessionMetadataStore::new(&root) + .load_metadata(&id) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn startup_indexes_all_sessions_without_copying_bodies_and_persistence_pages_survive_restart( + ) { + let (_temp, workspace, root, manager) = fixture(); + let candidates = (0..57) + .map(|id| candidate("codex", &id.to_string(), &workspace.root_path)) + .collect(); + assert_eq!( + register(&root, &workspace, &workspace, candidates) + .await + .unwrap(), + 57 + ); + assert!(SessionMetadataStore::new(&root) + .list_indexed_metadata() + .await + .unwrap() + .is_empty()); + let page = manager + .list_session_metadata_page(&root, None, 5) + .await + .unwrap(); + assert_eq!(page.total_top_level_count, 57); + assert_eq!(page.sessions.len(), 5); + assert!(page + .sessions + .iter() + .all(|metadata| metadata.turn_count == 0 && !metadata.needs_last_turn_backfill())); + let id = &page.sessions[0].session_id; + assert_eq!( + manager + .session_metadata_by_ids(&root, &[id.clone()]) + .await + .unwrap() + .len(), + 1 + ); + assert!(manager + .load_session_metadata(&root, id) + .await + .unwrap() + .is_some()); + assert!(!root.join(id).exists()); + CACHE.lock().await.remove(&root.join(INDEX_FILE)); + let restarted = PersistenceManager::new(manager.path_manager().clone()).unwrap(); + let next = restarted + .list_session_metadata_page(&root, page.next_cursor.as_deref(), 5) + .await + .unwrap(); + assert_eq!(next.total_top_level_count, 57); + assert!(next.sessions.iter().all(|metadata| !page + .sessions + .iter() + .any(|first| first.session_id == metadata.session_id))); + assert!( + std::fs::metadata(root.join(INDEX_FILE)).unwrap().len() < 128 * 1024, + "The startup index stores metadata only" + ); + } + + #[tokio::test] + async fn rename_archive_and_delete_remain_index_only_and_rediscovery_preserves_intent() { + let (_temp, workspace, root, manager) = fixture(); + let source = candidate("zcode", "one", &workspace.root_path); + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(); + let id = entry(&root).await.metadata.session_id; + manager + .update_session_metadata(&root, &id, |metadata| { + metadata.session_name = "My title".into(); + metadata.status = SessionStatus::Archived; + }) + .await + .unwrap(); + assert!(!root.join(&id).exists()); + assert!(manager + .list_session_metadata_page(&root, None, 5) + .await + .unwrap() + .sessions + .is_empty()); + let archived = manager + .list_session_metadata_including_internal(&root) + .await + .unwrap(); + assert_eq!(archived[0].session_name, "My title"); + assert_eq!( + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(), + 0 + ); + manager.delete_session(&root, &id).await.unwrap(); + assert_eq!( + register(&root, &workspace, &workspace, vec![source]) + .await + .unwrap(), + 0 + ); + assert!(manager + .load_session_metadata(&root, &id) + .await + .unwrap() + .is_none()); + assert!(manager + .list_session_metadata_page(&root, None, 5) + .await + .unwrap() + .sessions + .is_empty()); + } + + #[tokio::test] + async fn legacy_reference_materialization_preserves_user_metadata() { + let (_temp, workspace, root, manager) = fixture(); + let source = candidate("codex", "one", &workspace.root_path); + register( + &root, + &workspace, + &workspace, + vec![ + source.clone(), + candidate("codex", "two", &workspace.root_path), + ], + ) + .await + .unwrap(); + let id = super::super::auto_discovery::automatic_session_id(&workspace.id, "codex", "one"); + update_index(&root, |index| { + index + .entries + .get_mut(&id) + .unwrap() + .metadata + .custom_metadata + .as_mut() + .unwrap() + .as_object_mut() + .unwrap() + .remove("externalHistoryMode"); + ((), true) + }) + .await + .unwrap(); + manager + .update_session_metadata(&root, &id, |metadata| { + metadata.session_name = "My title".into(); + }) + .await + .unwrap(); + let reference = read_index(&root).await.unwrap().entries[&id].clone(); + materialize_reference( + &root, + reference, + workspace.clone(), + workspace.clone(), + vec![workspace.clone()], + move |_, _| Ok(document(source)), + ) + .await + .unwrap(); + let native = SessionMetadataStore::new(&root) + .list_indexed_metadata() + .await + .unwrap(); + assert_eq!(native.len(), 1); + assert!(!is_deferred(&native[0])); + assert_eq!(native[0].session_name, "My title"); + let (restored, turns, total) = manager + .load_session_with_tail_turns(&root, &id, 5) + .await + .unwrap(); + assert_eq!(total, 1); + assert_eq!(turns[0].user_message.content, "Saved question"); + assert_eq!(restored.session_id, id); + assert_eq!( + manager + .list_session_metadata_page(&root, None, 5) + .await + .unwrap() + .total_top_level_count, + 2 + ); + manager.delete_session(&root, &id).await.unwrap(); + register( + &root, + &workspace, + &workspace, + vec![candidate("codex", "one", &workspace.root_path)], + ) + .await + .unwrap(); + assert!(manager + .load_session_metadata(&root, &id) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn source_loss_and_movement_preserve_reference_without_publishing_empty_history() { + let (_temp, workspace, root, _) = fixture(); + let source = candidate("codex", "one", &workspace.root_path); + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(); + let reference = entry(&root).await; + let id = reference.metadata.session_id.clone(); + let error = materialize_reference( + &root, + reference.clone(), + workspace.clone(), + workspace.clone(), + vec![workspace.clone()], + |_, _| Err("source removed".into()), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("Restore the source")); + assert!(metadata(&root, &id).await.unwrap().is_some()); + assert!(!root.join(&id).exists()); + let mut moved = source; + moved.cwd = Some( + workspace + .root_path + .with_extension("elsewhere") + .to_string_lossy() + .into_owned(), + ); + assert!(materialize_reference( + &root, + reference, + workspace.clone(), + workspace.clone(), + vec![workspace], + move |_, _| Ok(document(moved)) + ) + .await + .is_err()); + assert!(!root.join(id).exists()); + } + + #[tokio::test] + async fn existing_native_copy_and_continuation_win_over_discovery() { + let (_temp, workspace, root, manager) = fixture(); + let source = candidate("zcode", "one", &workspace.root_path); + let mut bundle = convert_document( + "legacy-import", + &workspace, + &workspace, + "primary", + "Renamed", + &document(source.clone()), + true, + true, + ) + .unwrap(); + bundle.metadata.status = SessionStatus::Archived; + bundle.turns[0].user_message.content = "Local continuation".into(); + OfflineSessionImportStore::new(&root) + .write_bundle_atomic(&bundle) + .await + .unwrap(); + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(); + register(&root, &workspace, &workspace, vec![source]) + .await + .unwrap(); + let merged = manager + .list_session_metadata_including_internal(&root) + .await + .unwrap(); + assert_eq!(merged.len(), 2); + let independent = merged + .iter() + .find(|metadata| metadata.session_id == "legacy-import") + .unwrap(); + assert_eq!(independent.session_name, "Renamed"); + assert_eq!(independent.status, SessionStatus::Archived); + assert_eq!( + merged + .iter() + .filter(|metadata| tracking::is_followed(metadata)) + .count(), + 1 + ); + assert_eq!( + manager + .load_session_turns(&root, "legacy-import") + .await + .unwrap()[0] + .user_message + .content, + "Local continuation" + ); + manager + .delete_session(&root, "legacy-import") + .await + .unwrap(); + assert!( + has_references(&root).await.unwrap(), + "Removing a fixed native copy keeps the followed source" + ); + } + + #[tokio::test] + async fn old_index_payload_defaults_round_trip_and_unreadable_records_are_never_reset() { + let (_temp, workspace, root, _) = fixture(); + register( + &root, + &workspace, + &workspace, + vec![candidate("codex", "one", &workspace.root_path)], + ) + .await + .unwrap(); + let mut old = serde_json::to_value(read_index(&root).await.unwrap().as_ref()).unwrap(); + old.as_object_mut().unwrap().remove("schemaVersion"); + for entry in old["entries"].as_object_mut().unwrap().values_mut() { + entry.as_object_mut().unwrap().remove("dismissed"); + entry.as_object_mut().unwrap().remove("sourceTitle"); + entry.as_object_mut().unwrap().remove("fallbackTitle"); + } + let upgraded: Index = serde_json::from_value(old).unwrap(); + let round_trip: Index = + serde_json::from_value(serde_json::to_value(&upgraded).unwrap()).unwrap(); + assert_eq!(round_trip.schema_version, 1); + assert!(!round_trip.entries.values().next().unwrap().dismissed); + assert!(round_trip + .entries + .values() + .next() + .unwrap() + .source_title + .is_none()); + assert!(round_trip + .entries + .values() + .next() + .unwrap() + .fallback_title + .is_none()); + std::fs::write(root.join(INDEX_FILE), "{broken").unwrap(); + assert!(register( + &root, + &workspace, + &workspace, + vec![candidate("codex", "two", &workspace.root_path)] + ) + .await + .is_err()); + assert_eq!( + std::fs::read_to_string(root.join(INDEX_FILE)).unwrap(), + "{broken" + ); + } + + #[tokio::test] + async fn rediscovery_updates_an_archived_source_locator_without_resetting_its_title() { + let (_temp, workspace, root, manager) = fixture(); + let mut source = candidate("codex", "one", &workspace.root_path); + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(); + let id = entry(&root).await.metadata.session_id; + manager + .update_session_metadata(&root, &id, |metadata| { + metadata.session_name = "My title".into() + }) + .await + .unwrap(); + source.candidate_id = "codex:archived-location".into(); + assert_eq!( + register(&root, &workspace, &workspace, vec![source.clone()]) + .await + .unwrap(), + 0 + ); + let reference = entry(&root).await; + assert_eq!(reference.candidate_id, source.candidate_id); + assert_eq!(reference.metadata.session_name, "My title"); + materialize_reference( + &root, + reference, + workspace.clone(), + workspace.clone(), + vec![workspace], + move |_, candidate_id| { + assert_eq!(candidate_id, source.candidate_id); + Ok(document(source)) + }, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn a_broken_optional_index_keeps_native_history_visible_with_a_warning() { + let (_temp, workspace, root, manager) = fixture(); + let bundle = convert_document( + "native", + &workspace, + &workspace, + "primary", + "Native history", + &document(candidate("codex", "one", &workspace.root_path)), + true, + true, + ) + .unwrap(); + OfflineSessionImportStore::new(&root) + .write_bundle_atomic(&bundle) + .await + .unwrap(); + std::fs::write(root.join(INDEX_FILE), "{broken").unwrap(); + let page = manager + .list_session_metadata_page(&root, None, 5) + .await + .unwrap(); + assert_eq!(page.sessions.len(), 1); + assert_eq!(page.sessions[0].session_id, "native"); + assert_eq!(page.discovery_warnings.len(), 1); + assert_eq!(manager.list_session_metadata(&root).await.unwrap().len(), 1); + assert_eq!( + manager + .load_session_turns(&root, "native") + .await + .unwrap() + .len(), + 1 + ); + assert_eq!( + std::fs::read_to_string(root.join(INDEX_FILE)).unwrap(), + "{broken" + ); + } +} diff --git a/src/crates/assembly/core/src/external_session_import/deferred/legacy.rs b/src/crates/assembly/core/src/external_session_import/deferred/legacy.rs new file mode 100644 index 0000000000..cf63444fd7 --- /dev/null +++ b/src/crates/assembly/core/src/external_session_import/deferred/legacy.rs @@ -0,0 +1,285 @@ +//! Adopt old automatic imports lazily without creating a second navigation row. +//! Native files remain intact, including when validation finds local edits. +use super::*; + +const NATIVE: &str = "externalLegacyNative"; +const HASH: &str = "externalLegacyNativeHash"; +const INDEPENDENT: &str = "externalLegacyIndependent"; +const ALIAS: &str = "externalHistoryAliasOf"; + +pub(super) fn follows_native(metadata: &SessionMetadata) -> bool { + tracking::is_followed(metadata) + && metadata + .custom_metadata + .as_ref() + .is_some_and(|custom| custom[NATIVE] == true) +} + +pub(super) fn is_alias(metadata: &SessionMetadata) -> bool { + metadata + .custom_metadata + .as_ref() + .is_some_and(|custom| custom[ALIAS].is_string()) +} + +pub(super) fn project_metadata(index: &Index, metadata: &mut [SessionMetadata]) { + for native in metadata { + if let Some(entry) = index + .entries + .get(&native.session_id) + .filter(|entry| !entry.dismissed && follows_native(&entry.metadata)) + { + *native = entry.metadata.clone(); + } + if let Some(lineage) = native + .custom_metadata + .as_mut() + .and_then(|custom| custom.get_mut("externalBranchOf")) + { + let canonical = lineage["sessionId"] + .as_str() + .and_then(|id| index.entries.get(id)) + .and_then(|entry| entry.metadata.custom_metadata.as_ref()) + .and_then(|custom| custom[ALIAS].as_str()); + if let Some(id) = canonical { + lineage["sessionId"] = json!(id); + } + } + } +} + +pub(super) fn prepare_reference( + index: &mut Index, + native: &SessionMetadata, + candidate: &ExternalSessionCandidate, +) -> bool { + let Some(entry) = index.entries.get_mut(&native.session_id) else { + return false; + }; + if entry.dismissed + || entry + .metadata + .custom_metadata + .as_ref() + .is_some_and(|custom| custom[INDEPENDENT] == true) + { + return false; + } + let mut changed = false; + if !follows_native(&entry.metadata) { + let custom = entry + .metadata + .custom_metadata + .get_or_insert_with(|| json!({})); + custom[NATIVE] = json!(true); + custom["externalHistoryMode"] = json!("follow"); + custom[PENDING_KEY] = json!(true); + custom["continuationMode"] = json!("fork_before_continue"); + changed = true; + } + // Keep already-published IDs and snapshots usable by existing branch links, + // while exposing only the original automatic import in navigation. + for (id, other) in &mut index.entries { + if id != &native.session_id + && !other.dismissed + && tracking::is_followed(&other.metadata) + && other.metadata.workspace_id == native.workspace_id + && other.ecosystem_id == candidate.ecosystem_id + && other.source_session_id == candidate.source_session_id + { + let custom = other + .metadata + .custom_metadata + .get_or_insert_with(|| json!({})); + if custom[ALIAS] != native.session_id { + custom[ALIAS] = json!(native.session_id); + changed = true; + } + } + } + changed +} + +fn normalize(value: &mut Value) { + match value { + Value::Object(fields) => { + // Import timestamps can be synthesized for source records lacking a + // timestamp. Compare the content, provenance and execution records. + for key in ["timestamp", "startTime", "endTime", "durationMs"] { + fields.remove(key); + } + for key in [ + "userMessage", + "modelRounds", + "textItems", + "toolItems", + "thinkingItems", + ] { + if let Some(value) = fields.get_mut(key) { + normalize(value); + } + } + } + Value::Array(values) => values.iter_mut().for_each(normalize), + _ => {} + } +} + +fn unedited_prefix( + before: &OfflineSessionBundle, + after: &OfflineSessionBundle, +) -> OpenBitFunResult { + if can_repair_history(before, after) { + return Ok(true); + } + if before.metadata.status == SessionStatus::Active || before.turns.len() > after.turns.len() { + return Ok(false); + } + for (old, new) in before.turns.iter().zip(&after.turns) { + let mut old = old.clone(); + // A downgraded host may retain a view's versioned turn identity. + if old.turn_id + == format!( + "{}-v{}-turn-{}", + before.metadata.session_id, + before + .metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom["externalContentVersion"].as_str()) + .unwrap_or_default(), + old.turn_index + ) + { + old.turn_id = new.turn_id.clone(); + } + if let Some(metadata) = old + .user_message + .metadata + .as_mut() + .and_then(Value::as_object_mut) + { + metadata.remove("externalContentVersion"); + } + let mut old = serde_json::to_value(old).map_err(index_error)?; + let mut new = serde_json::to_value(new).map_err(index_error)?; + normalize(&mut old); + normalize(&mut new); + if old != new { + return Ok(false); + } + } + Ok(true) +} + +/// Called only when opening one source, never while discovering/listing it. +pub(super) async fn validate_native( + root: &Path, + entry: &Reference, + source: &OfflineSessionBundle, +) -> OpenBitFunResult { + if !follows_native(&entry.metadata) { + return Ok(true); + } + let Some(native) = OfflineSessionImportStore::new(root) + .load_bundle(&entry.metadata.session_id) + .await + .map_err(index_error)? + else { + return Ok(true); + }; + let hash = hex::encode(Sha256::digest( + serde_json::to_vec(&native.turns).map_err(index_error)?, + )); + if entry + .metadata + .custom_metadata + .as_ref() + .is_some_and(|custom| custom[HASH] == hash) + || unedited_prefix(&native, source)? + { + update_index(root, |index| { + let Some(current) = index.entries.get_mut(&entry.metadata.session_id) else { + return ((), false); + }; + current + .metadata + .custom_metadata + .get_or_insert_with(|| json!({}))[HASH] = json!(hash); + ((), true) + }) + .await?; + return Ok(true); + } + // A native continuation/edit already exists. Keep it and expose a separate + // source, reusing any duplicate reference created by the previous version. + update_index(root, |index| { + let mut native = native.metadata; + tracking::project_native_metadata(&mut native); + let custom = native.custom_metadata.get_or_insert_with(|| json!({})); + custom[INDEPENDENT] = json!(true); + custom[NATIVE] = json!(false); + if let Some(current) = index.entries.get_mut(&entry.metadata.session_id) { + current.metadata = native; + } + let alias = index + .entries + .iter() + .find(|(_, other)| { + !other.dismissed + && other + .metadata + .custom_metadata + .as_ref() + .is_some_and(|custom| custom[ALIAS] == entry.metadata.session_id) + }) + .map(|(id, _)| id.clone()); + if let Some(id) = alias { + index + .entries + .get_mut(&id) + .unwrap() + .metadata + .custom_metadata + .as_mut() + .unwrap() + .as_object_mut() + .unwrap() + .remove(ALIAS); + } else { + let mut id = format!("external-{}", entry.metadata.session_id); + let mut suffix = 2; + while index.entries.contains_key(&id) { + id = format!("external-{}-{suffix}", entry.metadata.session_id); + suffix += 1; + } + let mut source = entry.clone(); + source.metadata.session_id = id.clone(); + source.metadata.turn_count = 0; + source.metadata.message_count = 0; + source.metadata.tool_call_count = 0; + source.metadata.last_turn = None; + let custom = source + .metadata + .custom_metadata + .as_mut() + .unwrap() + .as_object_mut() + .unwrap(); + for key in [NATIVE, HASH, INDEPENDENT, ALIAS, "externalContentVersion"] { + custom.remove(key); + } + index.entries.insert(id, source); + } + ((), true) + }) + .await?; + Ok(false) +} + +pub(crate) async fn has_native_view(root: &Path, id: &str) -> OpenBitFunResult { + Ok(metadata(root, id) + .await? + .as_ref() + .is_some_and(follows_native)) +} diff --git a/src/crates/assembly/core/src/external_session_import/deferred/tracking.rs b/src/crates/assembly/core/src/external_session_import/deferred/tracking.rs new file mode 100644 index 0000000000..3e5137cb40 --- /dev/null +++ b/src/crates/assembly/core/src/external_session_import/deferred/tracking.rs @@ -0,0 +1,1341 @@ +//! Immutable source views. Only a fork publishes history into the native store. + +use super::*; + +const MODE: &str = "externalHistoryMode"; +const VERSION: &str = "externalContentVersion"; +const SNAPSHOTS: &str = ".external-history"; + +#[derive(Serialize, Deserialize)] +struct Snapshot { + schema_version: u32, + ecosystem_id: String, + source_session_id: String, + version: String, + bundle: OfflineSessionBundle, +} + +pub(crate) fn is_followed(metadata: &SessionMetadata) -> bool { + metadata + .custom_metadata + .as_ref() + .is_some_and(|value| value[MODE] == "follow") +} + +/// Older hosts can materialize a pending reference into a native copy. Its +/// physical native history takes precedence over optional view-only metadata. +pub(super) fn project_native_metadata(metadata: &mut SessionMetadata) { + if !is_followed(metadata) { + return; + } + if let Some(custom) = metadata + .custom_metadata + .as_mut() + .and_then(Value::as_object_mut) + { + for key in [ + MODE, + VERSION, + "externalSourceStatus", + "externalSourceError", + "externalObservedFingerprint", + "externalMaterializedObservation", + "externalSourceIncomplete", + ] { + custom.remove(key); + } + custom.insert(PENDING_KEY.into(), json!(false)); + custom.insert("continuationMode".into(), json!("openbitfun_local")); + } +} + +fn content_version(metadata: &SessionMetadata) -> Option<&str> { + metadata.custom_metadata.as_ref()?.get(VERSION)?.as_str() +} + +fn snapshot_path(root: &Path, id: &str, version: &str) -> OpenBitFunResult { + openbitfun_core_types::validate_session_id(id).map_err(OpenBitFunError::Validation)?; + if version.len() != 64 || !version.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(index_error("Invalid external history version")); + } + Ok(root + .join(SNAPSHOTS) + .join(id) + .join(format!("{version}.json"))) +} + +async fn reference(root: &Path, id: &str) -> OpenBitFunResult> { + Ok(read_index(root) + .await? + .entries + .get(id) + .filter(|entry| !entry.dismissed && is_followed(&entry.metadata)) + .cloned()) +} + +async fn read_snapshot( + root: &Path, + entry: &Reference, + version: &str, +) -> OpenBitFunResult { + let snapshot: Snapshot = JsonFileStore + .read_optional(&snapshot_path(root, &entry.metadata.session_id, version)?) + .await + .map_err(index_error)? + .ok_or_else(|| { + index_error( + "The viewed external history version is unavailable; reopen the source session", + ) + })?; + if snapshot.schema_version != 1 + || snapshot.version != version + || snapshot.ecosystem_id != entry.ecosystem_id + || snapshot.source_session_id != entry.source_session_id + || snapshot.bundle.metadata.session_id != entry.metadata.session_id + || content_version(&snapshot.bundle.metadata) != Some(version) + { + return Err(index_error( + "External history snapshot identity does not match its source", + )); + } + snapshot.bundle.validate().map_err(index_error)?; + let mut bundle = snapshot.bundle; + // Names and archive state belong to the local view, not to a frozen version. + bundle.metadata.session_name = entry.metadata.session_name.clone(); + bundle.metadata.tags = entry.metadata.tags.clone(); + bundle.metadata.status = entry.metadata.status.clone(); + if let (Some(current), Some(custom)) = ( + entry.metadata.custom_metadata.as_ref(), + bundle.metadata.custom_metadata.as_mut(), + ) { + for key in [ + "externalSourceStatus", + "externalSourceError", + "externalObservedFingerprint", + ] { + if let Some(value) = current.get(key) { + custom[key] = value.clone(); + } + } + } + Ok(bundle) +} + +/// A turn carries its immutable view version. Existing fork requests can pin it +/// without changing their wire shape or accidentally using an unseen update. +pub(crate) async fn snapshot_at_turn( + root: &Path, + id: &str, + turn_id: &str, +) -> OpenBitFunResult> { + let Some(entry) = reference(root, id).await? else { + return Ok(None); + }; + let suffix = turn_id.strip_prefix(&format!("{id}-v")).ok_or_else(|| { + index_error("A followed source must be forked from a viewed history version") + })?; + let (version, _) = suffix + .split_once("-turn-") + .ok_or_else(|| index_error("Invalid external history fork point"))?; + let bundle = read_snapshot(root, &entry, version).await?; + if !bundle.turns.iter().any(|turn| turn.turn_id == turn_id) { + return Err(index_error( + "The fork point does not belong to the viewed external history", + )); + } + Ok(Some(bundle)) +} + +pub(crate) async fn snapshot( + root: &Path, + id: &str, + refresh: bool, +) -> OpenBitFunResult> { + let Some(entry) = reference(root, id).await? else { + return Ok(None); + }; + if refresh || content_version(&entry.metadata).is_none() { + Box::pin(refresh_source(root, entry)).await?; + } + let Some(entry) = reference(root, id).await? else { + return Ok(None); + }; + let version = content_version(&entry.metadata) + .ok_or_else(|| index_error("The original source has no readable history snapshot"))?; + Ok(Some(read_snapshot(root, &entry, version).await?)) +} + +async fn refresh_source(root: &Path, entry: Reference) -> OpenBitFunResult<()> { + let service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| index_error("Workspace service is unavailable"))?; + let workspace_id = entry + .metadata + .workspace_id + .as_deref() + .ok_or_else(|| index_error("External history has no workspace owner"))?; + let workspace = service + .require_workspace(workspace_id) + .await + .map_err(index_error)?; + if workspace.workspace_kind == WorkspaceKind::Remote { + return Err(index_error( + "Following external history is unavailable for SSH workspaces", + )); + } + let (store, project) = import_storage(&workspace) + .await + .map_err(|error| index_error(error.detail))?; + if normalized_path(store.sessions_root()) != normalized_path(root) { + return Err(index_error( + "External history belongs to a different storage owner", + )); + } + let workspaces = service.list_workspace_infos().await; + refresh_reference(root, entry, workspace, project, workspaces, |source, id| { + source.read(id) + }) + .await +} + +async fn cache_unavailable_legacy_history( + root: &Path, + entry: &Reference, +) -> OpenBitFunResult { + if !super::legacy::follows_native(&entry.metadata) { + return Ok(false); + } + let Some(mut bundle) = OfflineSessionImportStore::new(root) + .load_bundle(&entry.metadata.session_id) + .await + .map_err(index_error)? + else { + return Ok(false); + }; + let id = &entry.metadata.session_id; + let version = hex::encode(Sha256::digest( + serde_json::to_vec(&("legacy-cache-v1", &bundle.turns)).map_err(index_error)?, + )); + bundle.metadata = metadata(root, id) + .await? + .unwrap_or_else(|| entry.metadata.clone()); + bundle + .metadata + .custom_metadata + .get_or_insert_with(|| json!({}))[VERSION] = json!(version); + for turn in &mut bundle.turns { + turn.turn_id = format!("{id}-v{version}-turn-{}", turn.turn_index); + turn.user_message.metadata.get_or_insert_with(|| json!({}))[VERSION] = json!(version); + } + let last_active = bundle.metadata.last_active_at; + let workspace_path = bundle.metadata.workspace_path.clone().unwrap_or_default(); + openbitfun_services_core::session::refresh_session_metadata_from_turns( + &mut bundle.metadata, + &workspace_path, + &bundle.turns, + last_active, + ); + let path = snapshot_path(root, id, &version)?; + JsonFileStore + .write_atomic_strict( + &path, + &Snapshot { + schema_version: 1, + ecosystem_id: entry.ecosystem_id.clone(), + source_session_id: entry.source_session_id.clone(), + version: version.clone(), + bundle: bundle.clone(), + }, + ) + .await + .map_err(index_error)?; + update_index(root, |index| { + let Some(current) = index.entries.get_mut(id).filter(|entry| !entry.dismissed) else { + return ((), false); + }; + current + .metadata + .custom_metadata + .get_or_insert_with(|| json!({}))[VERSION] = json!(version); + current.metadata.last_turn = bundle.metadata.last_turn; + ((), true) + }) + .await?; + Ok(true) +} + +pub(super) async fn refresh_reference( + root: &Path, + entry: Reference, + workspace: WorkspaceInfo, + project: WorkspaceInfo, + workspaces: Vec, + read: impl FnOnce(ImportSource, &str) -> Result + Send + 'static, +) -> OpenBitFunResult<()> { + let id = entry.metadata.session_id.clone(); + openbitfun_core_types::validate_session_id(&id).map_err(OpenBitFunError::Validation)?; + // The source lock is distinct from native Session writers. A snapshot is + // published before its index pointer, so a crash leaves the old view valid. + let _lock = JsonFileStore + .acquire_cross_process_lock(&root.join(SNAPSHOTS).join(&id).join("refresh")) + .await + .map_err(index_error)?; + let entry = reference(root, &id) + .await? + .ok_or_else(|| index_error("The external source view is no longer followed"))?; + let source = ImportSource::from_ecosystem(&entry.ecosystem_id) + .map_err(|error| index_error(error.detail))?; + let candidate = entry.candidate_id.clone(); + let document = match tokio::task::spawn_blocking(move || read(source, &candidate)) + .await + .map_err(index_error)? + { + Ok(document) => document, + Err(error) => { + update_index(root, |index| { + if let Some(entry) = index.entries.get_mut(&id).filter(|entry| !entry.dismissed) { + let custom = entry + .metadata + .custom_metadata + .get_or_insert_with(|| json!({})); + custom["externalSourceStatus"] = json!("unavailable"); + custom["externalSourceError"] = json!(error); + ((), true) + } else { + ((), false) + } + }) + .await?; + if content_version(&entry.metadata).is_some() { + return Ok(()); + } + if cache_unavailable_legacy_history(root, &entry).await? { + return Ok(()); + } + return Err(index_error(format!( + "The original {} history is unavailable. Restore the source and retry. {error}", + entry.ecosystem_id + ))); + } + }; + if document.candidate.ecosystem_id != entry.ecosystem_id + || document.candidate.source_session_id != entry.source_session_id + || !super::super::auto_discovery::belongs_to_workspace( + &document.candidate, + &workspace, + &workspaces, + ) + { + return Err(index_error( + "External history source identity or workspace changed; the cached view was preserved", + )); + } + let version = hex::encode(Sha256::digest( + serde_json::to_vec(&(source.revision(), &document.turns)).map_err(index_error)?, + )); + let mut bundle = convert_document( + &id, + &workspace, + &project, + &entry.metadata.agent_type, + &entry.metadata.session_name, + &document, + true, + true, + ) + .map_err(|error| index_error(error.detail))?; + if !super::legacy::validate_native(root, &entry, &bundle).await? { + return Ok(()); + } + bundle.metadata.tags = entry.metadata.tags.clone(); + bundle.metadata.status = entry.metadata.status.clone(); + let custom = bundle + .metadata + .custom_metadata + .get_or_insert_with(|| json!({})); + if let Some(previous) = entry + .metadata + .custom_metadata + .as_ref() + .and_then(Value::as_object) + { + for (key, value) in previous { + if key.starts_with("external") { + custom[key] = value.clone(); + } + } + } + custom[MODE] = json!("follow"); + custom[VERSION] = json!(version); + // This is still a reference with no native Session files. Retaining the old + // pending flag lets an older host open it as an independent native import. + custom["externalHistoryPending"] = json!(true); + custom["externalSourceStatus"] = json!("available"); + custom["externalSourceError"] = Value::Null; + custom["externalMaterializedObservation"] = entry + .metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get("externalObservedFingerprint")) + .cloned() + .unwrap_or(Value::Null); + custom["externalSourceIncomplete"] = + json!(document.turns.last().is_some_and(|turn| turn.status + != openbitfun_product_domains::session_import::ExternalSessionStatus::Complete)); + custom["continuationMode"] = json!("fork_before_continue"); + for turn in &mut bundle.turns { + turn.turn_id = format!("{id}-v{version}-turn-{}", turn.turn_index); + if let Some(metadata) = turn.user_message.metadata.as_mut() { + metadata[VERSION] = json!(version); + } + } + let last_active = bundle.metadata.last_active_at; + openbitfun_services_core::session::refresh_session_metadata_from_turns( + &mut bundle.metadata, + &workspace.root_path.to_string_lossy(), + &bundle.turns, + last_active, + ); + if let Some(last) = bundle.metadata.last_turn.as_mut() { + last.recovery_pending = Some(false); + } + bundle.validate().map_err(index_error)?; + let path = snapshot_path(root, &id, &version)?; + if !tokio::fs::try_exists(&path).await.map_err(index_error)? { + JsonFileStore + .write_atomic_strict( + &path, + &Snapshot { + schema_version: 1, + ecosystem_id: entry.ecosystem_id.clone(), + source_session_id: entry.source_session_id.clone(), + version, + bundle: bundle.clone(), + }, + ) + .await + .map_err(index_error)?; + } else { + // Never trust an interrupted or malformed cache merely because it exists. + read_snapshot(root, &entry, &version).await?; + } + update_index(root, |index| { + let Some(current) = index.entries.get_mut(&id).filter(|entry| !entry.dismissed) else { + return ((), false); + }; + // Preserve a concurrent rename/archive operation and a newer observation. + bundle.metadata.session_name = current.metadata.session_name.clone(); + bundle.metadata.tags = current.metadata.tags.clone(); + bundle.metadata.status = current.metadata.status.clone(); + if let Some(custom) = current.metadata.custom_metadata.as_ref() { + for key in [ + "externalSessionTitleOverride", + "externalObservedFingerprint", + "externalLegacyNativeHash", + ] { + if let Some(value) = custom.get(key) { + bundle.metadata.custom_metadata.as_mut().unwrap()[key] = value.clone(); + } + } + } + if document.candidate.title.is_some() + && (document.candidate.title_is_source_name || current.source_title.is_none()) + { + current.source_title = document.candidate.title.clone(); + } + current.fallback_title = document.candidate.fallback_title.clone(); + apply_source_title( + &mut current.metadata, + current.source_title.as_deref(), + current.fallback_title.as_deref(), + ); + bundle.metadata.session_name = current.metadata.session_name.clone(); + if let Some(title) = current + .metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get(SOURCE_TITLE_KEY)) + { + bundle.metadata.custom_metadata.as_mut().unwrap()[SOURCE_TITLE_KEY] = title.clone(); + } + current.metadata = bundle.metadata; + ((), true) + }) + .await +} + +pub(crate) fn branch_metadata(source: &SessionMetadata, target: &mut SessionMetadata) { + if !is_followed(source) { + return; + } + let source_custom = source.custom_metadata.as_ref().unwrap(); + let custom = target.custom_metadata.get_or_insert_with(|| json!({})); + if let Some(custom) = custom.as_object_mut() { + custom.retain(|key, _| { + !key.starts_with("external") + && !key.starts_with("source") + && key != "importedFrom" + && key != "continuationMode" + }); + custom.insert("externalBranchOf".into(), json!({ + "sessionId": source.session_id, "ecosystemId": source_custom["importedFrom"], + "sourceSessionId": source_custom["sourceSessionId"], "contentVersion": source_custom[VERSION] + })); + } +} + +#[cfg(test)] +mod tests { + use super::super::tests::fixture; + use super::*; + use crate::agentic::persistence::SessionBranchRequest; + use crate::external_session_import::auto_discovery::tests::{candidate, document}; + use openbitfun_services_core::session::SessionBranchBoundary; + + async fn create_source(root: &Path, workspace: &WorkspaceInfo) -> Reference { + register( + root, + workspace, + workspace, + vec![candidate("codex", "source-1", &workspace.root_path)], + ) + .await + .unwrap(); + read_index(root) + .await + .unwrap() + .entries + .values() + .find(|entry| is_followed(&entry.metadata)) + .unwrap() + .clone() + } + + async fn publish( + root: &Path, + workspace: &WorkspaceInfo, + entry: &Reference, + document: ExternalSessionDocument, + ) { + refresh_reference( + root, + entry.clone(), + workspace.clone(), + workspace.clone(), + vec![workspace.clone()], + move |_, _| Ok(document), + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn rediscovery_updates_source_activity_without_reading_history() { + let (_temp, workspace, root, _manager) = fixture(); + let entry = create_source(&root, &workspace).await; + let mut changed = candidate("codex", "source-1", &workspace.root_path); + changed.updated_at = Some(42_000); + changed.status = + openbitfun_product_domains::session_import::ExternalSessionStatus::Interrupted; + register(&root, &workspace, &workspace, vec![changed]) + .await + .unwrap(); + let latest = metadata(&root, &entry.metadata.session_id) + .await + .unwrap() + .unwrap(); + assert_eq!(latest.last_active_at, 42_000); + assert_eq!( + latest.custom_metadata.unwrap()["externalSourceIncomplete"], + true + ); + assert!(!root.join(SNAPSHOTS).exists()); + assert!(!root.join(&entry.metadata.session_id).exists()); + } + + #[tokio::test] + async fn append_rewrite_and_source_loss_preserve_viewed_versions_without_native_copies() { + let (_temp, workspace, root, manager) = fixture(); + let entry = create_source(&root, &workspace).await; + let id = &entry.metadata.session_id; + let first = document(candidate("codex", "source-1", &workspace.root_path)); + publish(&root, &workspace, &entry, first.clone()).await; + let viewed = snapshot(&root, id, false).await.unwrap().unwrap(); + assert_eq!(viewed.turns.len(), 1); + assert!(!root.join(id).join("metadata.json").exists()); + let mut appended = first.clone(); + let mut next = first.turns[0].clone(); + next.source_turn_id = "second".into(); + next.user_text = "A new question".into(); + appended.turns.push(next); + publish(&root, &workspace, &entry, appended).await; + assert_eq!( + snapshot(&root, id, false) + .await + .unwrap() + .unwrap() + .turns + .len(), + 2 + ); + let pinned = snapshot_at_turn(&root, id, &viewed.turns[0].turn_id) + .await + .unwrap() + .unwrap(); + assert_eq!( + pinned.turns.len(), + 1, + "forks use the view, not a later append" + ); + let mut rewritten = first; + rewritten.turns[0].user_text = "Rewritten source".into(); + publish(&root, &workspace, &entry, rewritten).await; + let latest = manager + .load_session_turns(&workspace.root_path, id) + .await + .unwrap(); + assert_eq!(latest.len(), 1); + assert_eq!(latest[0].user_message.content, "Rewritten source"); + assert_eq!( + snapshot_at_turn(&root, id, &viewed.turns[0].turn_id) + .await + .unwrap() + .unwrap() + .turns[0] + .user_message + .content, + "Saved question" + ); + refresh_reference( + &root, + entry.clone(), + workspace.clone(), + workspace.clone(), + vec![workspace.clone()], + |_, _| Err("Source is offline".into()), + ) + .await + .unwrap(); + let cached = snapshot(&root, id, false).await.unwrap().unwrap(); + assert_eq!(cached.turns[0].user_message.content, "Rewritten source"); + assert_eq!( + cached.metadata.custom_metadata.unwrap()["externalSourceStatus"], + "unavailable" + ); + assert!(!root.join(id).join("metadata.json").exists()); + } + + #[tokio::test] + async fn fork_is_native_and_immutable_while_source_continues_and_rejects_writes() { + let (_temp, workspace, root, manager) = fixture(); + let entry = create_source(&root, &workspace).await; + let id = &entry.metadata.session_id; + let first = document(candidate("codex", "source-1", &workspace.root_path)); + publish(&root, &workspace, &entry, first.clone()).await; + let viewed = snapshot(&root, id, false).await.unwrap().unwrap(); + let turn_id = viewed.turns[0].turn_id.clone(); + let mut latest = first; + latest.turns[0].user_text = "Later source content".into(); + publish(&root, &workspace, &entry, latest).await; + let branch_source = manager + .external_history_branch_source(&workspace.root_path, id, Some(&turn_id)) + .await + .unwrap() + .unwrap(); + let branch = manager + .branch_transient_session( + &workspace.root_path, + &SessionBranchRequest { + source_session_id: id.clone(), + source_turn_id: turn_id, + boundary: SessionBranchBoundary::ThroughTurn, + }, + branch_source, + ) + .await + .unwrap(); + let native = OfflineSessionImportStore::new(&root) + .load_bundle(&branch.session_id) + .await + .unwrap() + .unwrap(); + assert_eq!(native.turns[0].user_message.content, "Saved question"); + let custom = native.metadata.custom_metadata.as_ref().unwrap(); + assert_eq!(custom["externalBranchOf"]["sessionId"], *id); + assert!(custom.get("importedFrom").is_none()); + assert!(!is_followed(&native.metadata)); + assert!(manager + .assert_relay_history_supported(&workspace.root_path, id) + .await + .unwrap_err() + .to_string() + .contains("Relay is not supported")); + manager + .assert_relay_history_supported(&workspace.root_path, &branch.session_id) + .await + .unwrap(); + assert_eq!( + native.turns[0].user_message.metadata.as_ref().unwrap()["importedFrom"], + "codex" + ); + assert!(manager + .save_dialog_turn(&workspace.root_path, &viewed.turns[0]) + .await + .unwrap_err() + .to_string() + .contains("read-only")); + assert!(manager + .delete_dialog_turns_from(&workspace.root_path, id, 0) + .await + .is_err()); + assert!(!root.join(id).join("metadata.json").exists()); + assert_eq!( + merge_metadata(&root, vec![native.metadata]) + .await + .unwrap() + .len(), + 2 + ); + } + + #[tokio::test] + async fn history_catalog_and_paging_stay_on_the_viewed_version() { + use crate::service::session::SessionTurnWindowResponse; + use openbitfun_runtime_ports::SessionTurnWindowRequest; + let (_temp, workspace, root, manager) = fixture(); + let entry = create_source(&root, &workspace).await; + let id = &entry.metadata.session_id; + let first = document(candidate("codex", "source-1", &workspace.root_path)); + publish(&root, &workspace, &entry, first.clone()).await; + let viewed = snapshot(&root, id, false).await.unwrap().unwrap(); + let mut rewritten = first; + rewritten.turns[0].user_text = "An unseen rewrite".into(); + publish(&root, &workspace, &entry, rewritten).await; + let catalog = manager + .load_session_turn_catalog(&workspace.root_path, id, &viewed.turns, 1) + .await + .unwrap(); + assert_eq!( + catalog.entries[0].turn_id.as_deref(), + Some(viewed.turns[0].turn_id.as_str()) + ); + let window = manager + .load_session_turn_window(&SessionTurnWindowRequest { + workspace_path: workspace.root_path.clone(), + session_id: id.clone(), + include_internal: false, + target_storage_turn_index: viewed.turns[0].turn_index, + expected_turn_id: Some(viewed.turns[0].turn_id.clone()), + expected_catalog_revision: Some(catalog.revision), + before: 0, + after: 1, + }) + .await + .unwrap(); + let SessionTurnWindowResponse::Ready { turns, .. } = window else { + panic!("Viewed version must remain pageable") + }; + assert_eq!(turns[0].user_message.content, "Saved question"); + } + + #[tokio::test] + async fn legacy_automatic_import_reuses_one_row_and_preserves_native_files() { + let (_temp, workspace, root, manager) = fixture(); + let original = document(candidate("codex", "source-1", &workspace.root_path)); + let id = super::super::super::auto_discovery::automatic_session_id( + &workspace.id, + "codex", + "source-1", + ); + let native = convert_document( + &id, &workspace, &workspace, "primary", "Original", &original, true, true, + ) + .unwrap(); + let store = OfflineSessionImportStore::new(&root); + store.write_bundle_atomic(&native).await.unwrap(); + assert_eq!( + register( + &root, + &workspace, + &workspace, + vec![original.candidate.clone()] + ) + .await + .unwrap(), + 0 + ); + let entry = read_index(&root).await.unwrap().entries[&id].clone(); + // Simulate the duplicate source reference published by the previous build. + let mut duplicate = entry.clone(); + duplicate.metadata.session_id = format!("external-{id}"); + duplicate.metadata.custom_metadata.as_mut().unwrap()["externalLegacyNative"] = json!(false); + update_index(&root, |index| { + index + .entries + .insert(duplicate.metadata.session_id.clone(), duplicate); + ((), true) + }) + .await + .unwrap(); + register( + &root, + &workspace, + &workspace, + vec![original.candidate.clone()], + ) + .await + .unwrap(); + let rows = merge_metadata(&root, vec![native.metadata.clone()]) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].session_id, id); + assert!(is_followed(&rows[0])); + let mut old_branch = SessionMetadata::new( + "branch".into(), + "Branch".into(), + "primary".into(), + "primary".into(), + ); + old_branch.custom_metadata = Some( + json!({ "externalBranchOf": { "sessionId": format!("external-{id}"), "contentVersion": "older-view" } }), + ); + let projected = merge_metadata(&root, vec![native.metadata.clone(), old_branch]) + .await + .unwrap(); + assert_eq!( + projected + .iter() + .find(|row| row.session_id == "branch") + .unwrap() + .custom_metadata + .as_ref() + .unwrap()["externalBranchOf"]["sessionId"], + id + ); + assert!( + !root.join(SNAPSHOTS).exists(), + "Discovery must not read/copy history" + ); + publish(&root, &workspace, &entry, original.clone()).await; + let viewed = snapshot(&root, &id, false).await.unwrap().unwrap(); + assert!(viewed.turns[0].turn_id.contains("-v")); + assert!(manager + .external_history_branch_source( + &workspace.root_path, + &id, + Some(&viewed.turns[0].turn_id) + ) + .await + .unwrap() + .is_some()); + assert!(manager + .assert_native_history_mutation(&workspace.root_path, &id) + .await + .is_err()); + let mut updated = original; + updated.turns[0].user_text = "Source was rewritten".into(); + publish(&root, &workspace, &entry, updated).await; + assert_eq!( + snapshot(&root, &id, false).await.unwrap().unwrap().turns[0] + .user_message + .content, + "Source was rewritten" + ); + assert_eq!( + serde_json::to_value(store.load_bundle(&id).await.unwrap().unwrap().turns).unwrap(), + serde_json::to_value(native.turns).unwrap() + ); + assert_eq!( + merge_metadata(&root, vec![native.metadata]) + .await + .unwrap() + .len(), + 1 + ); + } + + #[tokio::test] + async fn legacy_cache_survives_first_open_offline_and_deletion_dismisses_aliases() { + for ecosystem in ["codex", "zcode"] { + let (_temp, workspace, root, manager) = fixture(); + let original = document(candidate(ecosystem, "source-1", &workspace.root_path)); + let id = super::super::super::auto_discovery::automatic_session_id( + &workspace.id, + ecosystem, + "source-1", + ); + let native = convert_document( + &id, &workspace, &workspace, "primary", "Original", &original, true, true, + ) + .unwrap(); + OfflineSessionImportStore::new(&root) + .write_bundle_atomic(&native) + .await + .unwrap(); + register( + &root, + &workspace, + &workspace, + vec![original.candidate.clone()], + ) + .await + .unwrap(); + let entry = read_index(&root).await.unwrap().entries[&id].clone(); + let mut alias = entry.clone(); + alias.metadata.session_id = format!("external-{id}"); + alias.metadata.custom_metadata.as_mut().unwrap()["externalLegacyNative"] = json!(false); + update_index(&root, |index| { + index + .entries + .insert(alias.metadata.session_id.clone(), alias); + ((), true) + }) + .await + .unwrap(); + register( + &root, + &workspace, + &workspace, + vec![original.candidate.clone()], + ) + .await + .unwrap(); + refresh_reference( + &root, + entry.clone(), + workspace.clone(), + workspace.clone(), + vec![workspace.clone()], + |_, _| Err("Source offline".into()), + ) + .await + .unwrap(); + let cached = snapshot(&root, &id, false).await.unwrap().unwrap(); + assert_eq!(cached.turns[0].user_message.content, "Saved question"); + assert_eq!( + cached.metadata.custom_metadata.unwrap()["externalSourceStatus"], + "unavailable" + ); + // Recovering the source reuses the same row; an offline cache was + // never mistaken for proof that a native copy is unedited. + publish(&root, &workspace, &entry, original.clone()).await; + assert_eq!( + merge_metadata(&root, vec![native.metadata]) + .await + .unwrap() + .len(), + 1 + ); + manager + .delete_session(&workspace.root_path, &id) + .await + .unwrap(); + register(&root, &workspace, &workspace, vec![original.candidate]) + .await + .unwrap(); + assert!(merge_metadata(&root, vec![]).await.unwrap().is_empty()); + assert!(read_index(&root) + .await + .unwrap() + .entries + .values() + .all(|entry| entry.dismissed)); + } + } + + #[tokio::test] + async fn older_host_native_materialization_remains_independent_after_upgrade() { + let (_temp, workspace, root, manager) = fixture(); + let entry = create_source(&root, &workspace).await; + let id = &entry.metadata.session_id; + let original = document(candidate("codex", "source-1", &workspace.root_path)); + publish(&root, &workspace, &entry, original.clone()).await; + let mut old_native = snapshot(&root, id, false).await.unwrap().unwrap(); + assert!( + is_deferred(&old_native.metadata), + "Older hosts must still see an unmaterialized reference" + ); + // The old writer retains unknown metadata while publishing native files. + old_native.metadata.custom_metadata.as_mut().unwrap()[PENDING_KEY] = json!(false); + old_native.turns[0].user_message.content = "Edited by an older host".into(); + OfflineSessionImportStore::new(&root) + .write_bundle_atomic(&old_native) + .await + .unwrap(); + let projected = manager + .load_session_metadata(&workspace.root_path, id) + .await + .unwrap() + .unwrap(); + assert!(!is_followed(&projected)); + manager + .assert_native_history_mutation(&workspace.root_path, id) + .await + .unwrap(); + assert!(manager + .external_history_branch_source(&workspace.root_path, id, None) + .await + .unwrap() + .is_none()); + assert_eq!( + register( + &root, + &workspace, + &workspace, + vec![original.candidate.clone()] + ) + .await + .unwrap(), + 0 + ); + // Discovery stays metadata-only. Opening recognizes the existing edit + // and only then exposes the independent native history alongside its source. + assert_eq!( + merge_metadata(&root, vec![old_native.metadata.clone()]) + .await + .unwrap() + .len(), + 1 + ); + publish(&root, &workspace, &entry, original.clone()).await; + let rows = merge_metadata(&root, vec![old_native.metadata]) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows.iter().filter(|row| is_followed(row)).count(), 1); + assert!(rows + .iter() + .any(|row| row.session_id == *id && !is_followed(row))); + assert_eq!( + manager + .load_session_turns(&workspace.root_path, id) + .await + .unwrap()[0] + .user_message + .content, + "Edited by an older host" + ); + let followed_id = rows + .iter() + .find(|row| is_followed(row)) + .unwrap() + .session_id + .clone(); + manager + .delete_session(&workspace.root_path, id) + .await + .unwrap(); + let index = read_index(&root).await.unwrap(); + assert!(index.entries[id].dismissed); + assert!(!is_followed(&index.entries[id].metadata)); + let mut updated = original.candidate; + updated.fingerprint = "after-old-copy-deletion".into(); + register(&root, &workspace, &workspace, vec![updated]) + .await + .unwrap(); + let followed = metadata(&root, &followed_id).await.unwrap().unwrap(); + assert_eq!( + followed.custom_metadata.unwrap()["externalObservedFingerprint"], + "after-old-copy-deletion" + ); + } + + #[tokio::test] + async fn renamed_archived_and_dismissed_sources_keep_local_intent() { + let (_temp, workspace, root, _manager) = fixture(); + let entry = create_source(&root, &workspace).await; + let first = document(candidate("codex", "source-1", &workspace.root_path)); + publish(&root, &workspace, &entry, first.clone()).await; + let mut metadata = metadata(&root, &entry.metadata.session_id) + .await + .unwrap() + .unwrap(); + metadata.session_name = "My source name".into(); + metadata.status = SessionStatus::Archived; + save_metadata(&root, &metadata).await.unwrap(); + publish(&root, &workspace, &entry, first).await; + let cached = snapshot(&root, &metadata.session_id, false) + .await + .unwrap() + .unwrap(); + assert_eq!(cached.metadata.session_name, "My source name"); + assert_eq!(cached.metadata.status, SessionStatus::Archived); + dismiss(&root, &metadata.session_id).await.unwrap(); + register( + &root, + &workspace, + &workspace, + vec![candidate("codex", "source-1", &workspace.root_path)], + ) + .await + .unwrap(); + assert!(merge_metadata(&root, vec![]).await.unwrap().is_empty()); + assert!(snapshot(&root, &metadata.session_id, false) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn removed_source_restores_the_same_view_without_changing_native_branches() { + let (_temp, workspace, root, manager) = fixture(); + let entry = create_source(&root, &workspace).await; + let id = &entry.metadata.session_id; + let original = document(candidate("codex", "source-1", &workspace.root_path)); + publish(&root, &workspace, &entry, original.clone()).await; + let viewed = snapshot(&root, id, false).await.unwrap().unwrap(); + let turn_id = viewed.turns[0].turn_id.clone(); + let branch_source = manager + .external_history_branch_source(&workspace.root_path, id, Some(&turn_id)) + .await + .unwrap() + .unwrap(); + let branch = manager + .branch_transient_session( + &workspace.root_path, + &SessionBranchRequest { + source_session_id: id.clone(), + source_turn_id: turn_id, + boundary: SessionBranchBoundary::ThroughTurn, + }, + branch_source, + ) + .await + .unwrap(); + let branch_path = root.join(&branch.session_id).join("metadata.json"); + let branch_before = tokio::fs::read(&branch_path).await.unwrap(); + + manager + .update_session_metadata(&workspace.root_path, id, |metadata| { + metadata.session_name = "My source name".into(); + metadata.status = SessionStatus::Archived; + }) + .await + .unwrap(); + publish(&root, &workspace, &entry, original.clone()).await; + assert_eq!( + metadata(&root, id).await.unwrap().unwrap().status, + SessionStatus::Archived + ); + assert_eq!(tokio::fs::read(&branch_path).await.unwrap(), branch_before); + + manager + .delete_session(&workspace.root_path, id) + .await + .unwrap(); + register( + &root, + &workspace, + &workspace, + vec![original.candidate.clone()], + ) + .await + .unwrap(); + assert!(metadata(&root, id).await.unwrap().is_none()); + let removed = removed_metadata(&root, &workspace.id, "codex") + .await + .unwrap(); + assert_eq!(removed.len(), 1); + assert_eq!(removed[0].session_id, *id); + assert_eq!(tokio::fs::read(&branch_path).await.unwrap(), branch_before); + assert_eq!( + manager + .load_session_turns(&workspace.root_path, &branch.session_id) + .await + .unwrap()[0] + .user_message + .content, + "Saved question" + ); + + manager + .restore_external_session_reference(&workspace.root_path, &workspace.id, "codex", id) + .await + .unwrap(); + register(&root, &workspace, &workspace, vec![original.candidate]) + .await + .unwrap(); + let restored = snapshot(&root, id, false).await.unwrap().unwrap(); + assert_eq!(restored.metadata.session_name, "My source name"); + assert_eq!(restored.metadata.status, SessionStatus::Active); + assert_eq!(restored.turns[0].turn_id, viewed.turns[0].turn_id); + assert!(removed_metadata(&root, &workspace.id, "codex") + .await + .unwrap() + .is_empty()); + assert!(!root.join(id).join("metadata.json").exists()); + assert_eq!(tokio::fs::read(&branch_path).await.unwrap(), branch_before); + + manager + .update_session_metadata(&workspace.root_path, &branch.session_id, |metadata| { + metadata.status = SessionStatus::Archived; + }) + .await + .unwrap(); + assert_eq!( + metadata(&root, id).await.unwrap().unwrap().status, + SessionStatus::Active + ); + manager + .delete_session(&workspace.root_path, &branch.session_id) + .await + .unwrap(); + assert_eq!( + snapshot(&root, id, false).await.unwrap().unwrap().turns[0].turn_id, + viewed.turns[0].turn_id + ); + assert!(removed_metadata(&root, &workspace.id, "codex") + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn restored_reference_is_idempotent_owned_and_preserves_legacy_aliases() { + let (_temp, workspace, root, _manager) = fixture(); + let entry = create_source(&root, &workspace).await; + let id = &entry.metadata.session_id; + let mut alias = entry.clone(); + alias.metadata.session_id = format!("alias-{id}"); + alias.metadata.custom_metadata.as_mut().unwrap()["externalHistoryAliasOf"] = json!(id); + alias.dismissed = true; + update_index(&root, |index| { + index + .entries + .insert(alias.metadata.session_id.clone(), alias); + ((), true) + }) + .await + .unwrap(); + dismiss(&root, id).await.unwrap(); + assert!(restore_reference(&root, "another-workspace", "codex", id) + .await + .is_err()); + assert!(restore_reference(&root, &workspace.id, "zcode", id) + .await + .is_err()); + assert!(metadata(&root, id).await.unwrap().is_none()); + assert_eq!( + removed_metadata(&root, &workspace.id, "codex") + .await + .unwrap() + .len(), + 1 + ); + restore_reference(&root, &workspace.id, "codex", id) + .await + .unwrap(); + let mut archived = metadata(&root, id).await.unwrap().unwrap(); + archived.status = SessionStatus::Archived; + save_metadata(&root, &archived).await.unwrap(); + restore_reference(&root, &workspace.id, "codex", id) + .await + .unwrap(); + assert_eq!( + metadata(&root, id).await.unwrap().unwrap().status, + SessionStatus::Archived + ); + let mut updated = candidate("codex", "source-1", &workspace.root_path); + updated.fingerprint = "after-restoring".into(); + register(&root, &workspace, &workspace, vec![updated]) + .await + .unwrap(); + let visible = merge_metadata(&root, vec![]).await.unwrap(); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].session_id, *id); + assert_eq!( + visible[0].custom_metadata.as_ref().unwrap()["externalObservedFingerprint"], + "after-restoring" + ); + assert!(read_index(&root) + .await + .unwrap() + .entries + .values() + .all(|entry| !entry.dismissed)); + } + + #[tokio::test] + async fn legacy_native_import_remains_independent_and_source_gets_its_own_view() { + let (_temp, workspace, root, _manager) = fixture(); + let document = document(candidate("codex", "source-1", &workspace.root_path)); + let mut native = convert_document( + "old-import", + &workspace, + &workspace, + "primary", + "Old copy", + &document, + true, + true, + ) + .unwrap(); + native.turns[0].user_message.content = "Locally edited".into(); + let store = OfflineSessionImportStore::new(&root); + store.write_bundle_atomic(&native).await.unwrap(); + let entry = create_source(&root, &workspace).await; + assert_ne!(entry.metadata.session_id, "old-import"); + publish(&root, &workspace, &entry, document).await; + assert_eq!( + store + .load_bundle("old-import") + .await + .unwrap() + .unwrap() + .turns[0] + .user_message + .content, + "Locally edited" + ); + let legacy = serde_json::to_value(&native.metadata).unwrap(); + assert!(!is_followed( + &serde_json::from_value::(legacy.clone()).unwrap() + )); + assert_eq!( + serde_json::to_value( + serde_json::from_value::(legacy.clone()).unwrap() + ) + .unwrap(), + legacy + ); + dismiss(&root, "old-import").await.unwrap(); + assert!(snapshot(&root, &entry.metadata.session_id, false) + .await + .unwrap() + .is_some()); + } + + #[tokio::test] + async fn foreign_identity_and_invalid_fork_versions_cannot_replace_or_escape_cache() { + let (_temp, workspace, root, _manager) = fixture(); + let entry = create_source(&root, &workspace).await; + let id = &entry.metadata.session_id; + let first = document(candidate("codex", "source-1", &workspace.root_path)); + publish(&root, &workspace, &entry, first).await; + let foreign = document(candidate("codex", "other-source", &workspace.root_path)); + assert!(refresh_reference( + &root, + entry.clone(), + workspace.clone(), + workspace.clone(), + vec![workspace.clone()], + move |_, _| Ok(foreign) + ) + .await + .is_err()); + assert!( + snapshot_at_turn(&root, id, &format!("{id}-v../../outside-turn-0")) + .await + .is_err() + ); + assert!(snapshot_at_turn(&root, id, "legacy-unversioned-turn") + .await + .is_err()); + assert_eq!( + snapshot(&root, id, false).await.unwrap().unwrap().turns[0] + .user_message + .content, + "Saved question" + ); + } +} diff --git a/src/crates/assembly/core/src/external_session_import/lifecycle.rs b/src/crates/assembly/core/src/external_session_import/lifecycle.rs new file mode 100644 index 0000000000..9076038e02 --- /dev/null +++ b/src/crates/assembly/core/src/external_session_import/lifecycle.rs @@ -0,0 +1,100 @@ +//! Explicit local-view lifecycle. Discovery never clears a removal marker. +use super::*; +use crate::service::workspace::WorkspaceKind; +use openbitfun_product_domains::session_import::{ + RemovedExternalSession, RestoreRemovedExternalSessionRequest, +}; + +pub async fn list_removed_external_sessions( + ecosystem_id: &str, +) -> ExternalSourceOperationResult> { + ImportSource::from_ecosystem(ecosystem_id)?; + let service = crate::service::workspace::get_global_workspace_service().ok_or_else(|| { + operation( + ExternalSourceOperationErrorCode::Unavailable, + "Workspace service is unavailable", + true, + ) + })?; + let mut removed = Vec::new(); + for workspace in service.list_workspace_infos().await { + // A remote workspace's local storage directory is never a source host. + if workspace.workspace_kind == WorkspaceKind::Remote { + continue; + } + let (store, _) = import_storage(&workspace).await?; + let entries = + deferred::removed_metadata(store.sessions_root(), &workspace.id, ecosystem_id) + .await + .map_err(lifecycle_error)?; + removed.extend(entries.into_iter().map(|metadata| RemovedExternalSession { + ecosystem_id: ecosystem_id.into(), + session_id: metadata.session_id, + session_name: metadata.session_name, + workspace_id: workspace.id.clone(), + workspace_name: workspace.name.clone(), + })); + } + removed.sort_by(|a, b| { + (&a.workspace_name, &a.session_name, &a.session_id).cmp(&( + &b.workspace_name, + &b.session_name, + &b.session_id, + )) + }); + Ok(removed) +} + +pub async fn restore_removed_external_session( + request: RestoreRemovedExternalSessionRequest, +) -> ExternalSourceOperationResult<()> { + ImportSource::from_ecosystem(&request.ecosystem_id)?; + let service = crate::service::workspace::get_global_workspace_service().ok_or_else(|| { + operation( + ExternalSourceOperationErrorCode::Unavailable, + "Workspace service is unavailable", + true, + ) + })?; + let workspace = service + .require_workspace(&request.target_workspace_id) + .await + .map_err(lifecycle_error)?; + if workspace.workspace_kind == WorkspaceKind::Remote { + return Err(operation( + ExternalSourceOperationErrorCode::Unsupported, + "Restoring external source sessions is unavailable for SSH workspaces", + false, + )); + } + let coordinator = crate::agentic::coordination::get_global_coordinator().ok_or_else(|| { + operation( + ExternalSourceOperationErrorCode::Unavailable, + "Runtime ownership is unavailable for external source sessions", + true, + ) + })?; + coordinator + .ensure_workspace_runtime_ownership(&workspace.root_path, None, None) + .map_err(lifecycle_error)?; + let (store, _) = import_storage(&workspace).await?; + coordinator + .get_session_manager() + .persistence_manager() + .restore_external_session_reference( + store.sessions_root(), + &workspace.id, + &request.ecosystem_id, + &request.session_id, + ) + .await + .map_err(lifecycle_error) +} + +fn lifecycle_error(error: impl std::fmt::Display) -> ExternalSourceOperationError { + operation( + ExternalSourceOperationErrorCode::Unavailable, + error.to_string(), + true, + ) +} diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index c777ec5143..784e6e8188 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -20,6 +20,8 @@ pub mod external_mcp_import; #[cfg(all(test, feature = "external-sources"))] mod external_mcp_tests; #[cfg(feature = "external-sources")] +pub mod external_session_import; +#[cfg(feature = "external-sources")] pub mod external_sources; #[cfg(feature = "external-sources")] mod external_subagents; diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 238d18425a..bc91780522 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -1253,6 +1253,15 @@ impl CoreAgentRuntimeCompatibility { &self, workspace_path: &Path, ) -> OpenBitFunResult> { + #[cfg(feature = "external-sources")] + for warning in crate::external_session_import::auto_discovery::discover_for_storage_path( + workspace_path, + ) + .await + .warnings + { + log::warn!("External session discovery: {warning}"); + } self.persistence.list_session_metadata(workspace_path).await } @@ -1262,9 +1271,22 @@ impl CoreAgentRuntimeCompatibility { cursor: Option<&str>, limit: usize, ) -> OpenBitFunResult { - self.persistence + #[cfg(feature = "external-sources")] + let warnings = crate::external_session_import::auto_discovery::discover_for_storage_path( + workspace_path, + ) + .await; + #[allow(unused_mut)] + let mut page = self + .persistence .list_session_metadata_page(workspace_path, cursor, limit) - .await + .await?; + #[cfg(feature = "external-sources")] + { + page.discovery_warnings.extend(warnings.warnings); + page.discovery_pending = warnings.pending; + } + Ok(page) } /// A bounded navigation read over metadata + current runtime owners. Initial @@ -1439,6 +1461,19 @@ impl CoreAgentRuntimeCompatibility { } let metadata = self.list_persisted_sessions(storage_path).await?; + #[cfg(feature = "external-sources")] + let has_deferred_history = metadata.iter().any(|metadata| { + crate::external_session_import::deferred::is_deferred(metadata) + || crate::external_session_import::deferred::tracking::is_followed(metadata) + }); + #[cfg(feature = "external-sources")] + let metadata = metadata + .into_iter() + .filter(|metadata| { + !crate::external_session_import::deferred::is_deferred(metadata) + && !crate::external_session_import::deferred::tracking::is_followed(metadata) + }) + .collect::>(); let documents = metadata .iter() .map(session_search_document) @@ -1459,6 +1494,15 @@ impl CoreAgentRuntimeCompatibility { .map_err(session_search_index_error)?; let mut diagnostics = Vec::new(); + #[cfg(feature = "external-sources")] + if has_deferred_history { + diagnostics.push(SessionSearchDiagnostic { + code: SessionSearchDiagnosticCode::SessionIndexStale, + session_id: None, + message: "Followed external history is excluded from workspace content search; fork it into a native session to include it".into(), + }); + } + for session_id in reconciliation.stale_session_ids { let Some(source_revision) = revisions.get(&session_id) else { continue; @@ -2170,6 +2214,44 @@ impl CoreSessionOperationsPort { .await } + #[cfg(feature = "external-sources")] + async fn fork_external_history_at_turn( + &self, + storage_path: &Path, + source_session_id: &str, + source_turn_id: Option<&str>, + boundary: SessionBranchBoundary, + ) -> PortResult> { + let Some(source) = self + .persistence + .external_history_branch_source(storage_path, source_session_id, source_turn_id) + .await + .map_err(runtime_port_error)? + else { + return Ok(None); + }; + let source_turn_id = match source_turn_id { + Some(id) => id.to_owned(), + None => latest_persisted_turn_id(&source.turns).map_err(runtime_port_error)?, + }; + let result = self + .persistence + .branch_transient_session( + storage_path, + &SessionBranchRequest { + source_session_id: source_session_id.to_owned(), + source_turn_id, + boundary, + }, + source, + ) + .await + .map_err(runtime_port_error)?; + self.finish_session_fork(storage_path, source_session_id, result) + .await + .map(Some) + } + async fn fork_at_persisted_turn( &self, storage_path: &Path, @@ -2230,6 +2312,18 @@ impl CoreSessionOperationsPort { .map_err(runtime_port_error)?; (execution_id.to_owned(), is_transient) }; + // Keep the optional source decoding/fork state out of native restore futures. + #[cfg(feature = "external-sources")] + if let Some(result) = Box::pin(self.fork_external_history_at_turn( + storage_path, + &source_session_id, + source_turn_id.as_deref(), + boundary, + )) + .await? + { + return Ok(result); + } if is_transient { let _guard = session_manager .acquire_session_mutation(&source_session_id) diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 78fb11346c..a1e7c2828f 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -1134,6 +1134,8 @@ mod tests { ("app.right_panel.width", json!(420)), ("app.flow_chat.show_permission_mode_control", json!(false)), ("app.hooks.project_hooks_enabled", json!(true)), + ("app.external_session_discovery.codex", json!(true)), + ("app.external_session_discovery.zcode", json!(false)), ( "app.keybindings", json!({"version": 1, "overrides": {"session.new": {"key": "n", "alt": true}}}), diff --git a/src/crates/contracts/config-contracts/src/types.rs b/src/crates/contracts/config-contracts/src/types.rs index 89cb473ac6..6e36c568ea 100644 --- a/src/crates/contracts/config-contracts/src/types.rs +++ b/src/crates/contracts/config-contracts/src/types.rs @@ -203,6 +203,10 @@ pub struct AppConfig { /// Defaults for opt-in managed Git worktrees. #[serde(default)] pub worktrees: WorktreeSettings, + /// Opt-in discovery of external session history on the serving host. + /// Unknown ecosystem keys survive upgrades; only installed readers consume them. + #[serde(default)] + pub external_session_discovery: HashMap, } /// An explicit empty source list stays empty; defaults apply only to absent settings. @@ -1932,6 +1936,7 @@ impl Default for AppConfig { close_button_behavior: default_close_button_behavior(), hooks: AgentHooksConfig::default(), worktrees: WorktreeSettings::default(), + external_session_discovery: HashMap::new(), } } } @@ -4204,3 +4209,25 @@ mod user_question_timeout_tests { } } } + +#[cfg(test)] +mod external_session_discovery_tests { + use super::*; + + #[test] + fn legacy_configuration_stays_opted_out_and_unknown_agent_preferences_round_trip() { + let mut legacy = serde_json::to_value(AppConfig::default()).unwrap(); + legacy + .as_object_mut() + .unwrap() + .remove("external_session_discovery"); + legacy["language"] = serde_json::json!("en-US"); + let old: AppConfig = serde_json::from_value(legacy.clone()).unwrap(); + assert!(old.external_session_discovery.is_empty()); + assert_eq!(old.language, "en-US"); + legacy["external_session_discovery"] = + serde_json::json!({"codex": true, "zcode": false, "future-agent": true}); + let config: AppConfig = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(serde_json::to_value(config).unwrap(), legacy); + } +} diff --git a/src/crates/contracts/product-domains/AGENTS.md b/src/crates/contracts/product-domains/AGENTS.md index b4bcaca054..a79196e94e 100644 --- a/src/crates/contracts/product-domains/AGENTS.md +++ b/src/crates/contracts/product-domains/AGENTS.md @@ -68,6 +68,12 @@ cargo check -p openbitfun-core --features product-full For documentation-only changes, run `git diff --check`. +For external session catalog and persisted candidate upgrade compatibility: + +```bash +cargo test -p openbitfun-product-domains --no-default-features --features external-sources --lib session_import::tests +``` + For MiniApp bridge/session or embedded bundle changes, use the focused contracts: ```bash diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index e6c2c0bdb4..385f02f7c3 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "a0070347f9527a6eddeb84b5cd0a93d345c378aa008ce857976f02a9f4bd3d2f", + "digest": "1cd850650aa8b8df165b6fe69c90f060c240169f4becf163ecaba3fbe037000b", "ownerDigest": "b96bf9292a92548facbd835b8b89c4865bfdd984b356c9001995f552c6a34f51", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 324, + "documentedItems": 326, "controlCoverage": { "direct": 50, "delegated": 62, - "interactive": 212, + "interactive": 214, "unsupported": 0 } }, @@ -7864,6 +7864,8 @@ "itemIds": [ "source-discovery", "source-policy", + "codex-session-import", + "zcode-session-import", "prompts-commands", "subagents", "mcp-import", @@ -8003,6 +8005,98 @@ "actionId": "surface.ecosystemCompatibility.open" } }, + { + "id": "feature.ecosystem-compatibility:open:codex-session-import", + "capabilityId": "feature.ecosystem-compatibility", + "itemIds": [ + "codex-session-import" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "action", + "actionId": "surface.ecosystemCompatibility.open" + } + }, + { + "id": "feature.ecosystem-compatibility:open:zcode-session-import", + "capabilityId": "feature.ecosystem-compatibility", + "itemIds": [ + "zcode-session-import" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "action", + "actionId": "surface.ecosystemCompatibility.open" + } + }, { "id": "feature.ecosystem-compatibility:open:prompts-commands", "capabilityId": "feature.ecosystem-compatibility", @@ -23480,8 +23574,8 @@ "categoryId": "automation", "titleZh": "外部导入", "titleEn": "External Import", - "summaryZh": "查看五类外部 AI 生态的内容与状态,导入受支持的 Skill、MCP 和 Hook 副本,并查看 Command、Tool、子智能体的兼容使用状态。", - "summaryEn": "Inspect content and status from five external AI ecosystems, import supported Skill, MCP and Hook copies, and review compatibility usage for commands, tools and subagents.", + "summaryZh": "查看外部 AI 生态的内容与状态,导入受支持的 Skill、MCP、Hook,以及 Codex、Zcode 会话,并在 OpenBitFun 中继续对话。", + "summaryEn": "Inspect external AI content and status, import supported Skills, MCP, Hooks, and Codex or Zcode sessions, and continue conversations in OpenBitFun.", "keywordsZh": [ "外部导入", "生态兼容", @@ -23490,7 +23584,10 @@ "OpenCode", "Claude Code", "Codex", - "导入" + "导入", + "Zcode", + "智谱", + "会话导入" ], "keywordsEn": [ "external import", @@ -23501,19 +23598,24 @@ "OpenCode", "Claude Code", "Codex", - "import" + "import", + "Zcode", + "Z.ai", + "session import" ], "highlightsZh": [ "按已识别与更多应用浏览;可发现表示支持查找,分类状态表示当前结果", "自动发现只控制列表更新;关闭后保留结果,仍可手动刷新,已接入的功能照常运行", "审阅并导入受支持的 Skill、MCP 与 Hook;Command、Tool、子智能体暂无副本导入", - "已导入表示副本已保存;在对应管理页确认启用与连接" + "已导入表示副本已保存;在对应管理页确认启用与连接", + "Codex、Zcode 会话按工作区自动发现;打开来源时刷新历史,首次发送创建独立分支,可移除和重新添加来源" ], "highlightsEn": [ "Browse identified and more apps; discovery coverage and current category results are separate", "Automatic discovery controls list updates; pausing retains results and allows manual refresh without changing active integrations", "Review and import supported Skills, MCP and Hooks; command, tool and subagent copy import is unavailable", - "Imported means a copy was saved; confirm activation and connection in the relevant settings" + "Imported means a copy was saved; confirm activation and connection in the relevant settings", + "Discover Codex and Zcode sessions by workspace, refresh history on open, continue in a new branch on first send, and remove or restore source views" ], "items": [ { @@ -23538,6 +23640,28 @@ "reasonEn": "“Enable or disable each source and choose managed, preview, or read-only policy” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "codex-session-import", + "titleZh": "发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊", + "titleEn": "Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊”由用户在生态兼容页开启发现或选择已移除的来源。打开来源时刷新历史,首次发送创建独立分支。归档、移除和分支删除各自独立,原应用历史保持不变。智能体打开入口,由用户选择具体会话。", + "reasonEn": "“Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches” requires the user to enable discovery or select a removed source in ecosystem compatibility. Opening a source refreshes its history and first send creates an independent branch. Archives, source removal and branch deletion stay independent; original history is preserved. The Agent opens the entry for the user to choose a session." + } + }, + { + "id": "zcode-session-import", + "titleZh": "发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊", + "titleEn": "Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊”由用户在生态兼容页开启发现或选择已移除的来源。打开来源时刷新历史,首次发送创建独立分支。归档、移除和分支删除各自独立,原应用历史保持不变。智能体打开入口,由用户选择具体会话。", + "reasonEn": "“Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches” requires the user to enable discovery or select a removed source in ecosystem compatibility. Opening a source refreshes its history and first send creates an independent branch. Archives, source removal and branch deletion stay independent; original history is preserved. The Agent opens the entry for the user to choose a session." + } + }, { "id": "prompts-commands", "titleZh": "展开外部 Prompt/Command 内容并处理与原生命令的冲突", @@ -23657,24 +23781,35 @@ "Claude Code", "Codex", "导入", + "Zcode", + "智谱", + "会话导入", "external import", "external imports", "ecosystem compatibility", "external AI", "configuration migration", "import", + "Z.ai", + "session import", "按已识别与更多应用浏览;可发现表示支持查找,分类状态表示当前结果", "自动发现只控制列表更新;关闭后保留结果,仍可手动刷新,已接入的功能照常运行", "审阅并导入受支持的 Skill、MCP 与 Hook;Command、Tool、子智能体暂无副本导入", "已导入表示副本已保存;在对应管理页确认启用与连接", + "Codex、Zcode 会话按工作区自动发现;打开来源时刷新历史,首次发送创建独立分支,可移除和重新添加来源", "Browse identified and more apps; discovery coverage and current category results are separate", "Automatic discovery controls list updates; pausing retains results and allows manual refresh without changing active integrations", "Review and import supported Skills, MCP and Hooks; command, tool and subagent copy import is unavailable", "Imported means a copy was saved; confirm activation and connection in the relevant settings", + "Discover Codex and Zcode sessions by workspace, refresh history on open, continue in a new branch on first send, and remove or restore source views", "发现 OpenCode、Claude Code、Codex 等外部 AI 来源与可用内容", "Discover OpenCode, Claude Code, Codex, and other external AI sources and content", "按来源启用或停用集成,并选择管理、预览或只读策略", "Enable or disable each source and choose managed, preview, or read-only policy", + "发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊", + "Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches", + "发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊", + "Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches", "展开外部 Prompt/Command 内容并处理与原生命令的冲突", "Expand external prompt/command content and resolve conflicts with native commands", "启用外部智能体,处理同名冲突并绑定模型", diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 75931248d8..8aa720ef80 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:01a8acb268a90398", + "digest": "fnv1a64:b89f0fab51cf3cff", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -4202,6 +4202,18 @@ "kind": "handled" } }, + { + "id": "list_removed_external_sessions", + "surface": "tauri_command", + "remoteWorkspace": "RemoteUnsupported", + "peer": { + "kind": "proxied" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the CLI peer host has no handler for this command" + } + }, { "id": "list_sessions", "surface": "tauri_command", @@ -6136,6 +6148,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "restore_removed_external_session", + "surface": "tauri_command", + "remoteWorkspace": "RemoteUnsupported", + "peer": { + "kind": "proxied" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the CLI peer host has no handler for this command" + } + }, { "id": "restore_session", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index 8f9359d800..408fb380ef 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -13,6 +13,8 @@ pub mod product_control_owner_registry; pub mod product_release; pub mod product_search; pub mod remote_surface; +#[cfg(feature = "external-sources")] +pub mod session_import; pub mod tool_permissions; #[cfg(feature = "appearance-market")] diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 8a31fbd2a3..0173c65631 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -438,6 +438,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("list_persisted_sessions_page", Unaudited, Proxied, HANDLED), op("list_project_permission_audit", Agnostic, Proxied, HANDLED), op("list_project_permission_grants", Agnostic, Proxied, HANDLED), + op("list_removed_external_sessions", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("list_sessions", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("list_skill_market", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("list_subagents", Routed, Proxied, HANDLED), @@ -601,6 +602,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("respond_permission_batch", Agnostic, Proxied, HANDLED), op("restart_app", LocalOnly, ControllerLocal, REFUSED), op("restart_mcp_server", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("restore_removed_external_session", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("restore_session", Unaudited, Proxied, HANDLED), op("restore_session_view", Unaudited, Proxied, HANDLED), op("restore_session_with_turns", Unaudited, Proxied, HANDLED), diff --git a/src/crates/contracts/product-domains/src/session_import.rs b/src/crates/contracts/product-domains/src/session_import.rs new file mode 100644 index 0000000000..4c306cd846 --- /dev/null +++ b/src/crates/contracts/product-domains/src/session_import.rs @@ -0,0 +1,183 @@ +//! Product contracts for importing external agent sessions. +//! +//! These DTOs deliberately describe the import boundary only. Ecosystem +//! adapters own their source formats; Assembly owns conversion into the +//! OpenBitFun Session storage model. + +use serde::{Deserialize, Serialize}; + +pub const EXTERNAL_SESSION_IMPORT_SCHEMA_V1: u32 = 1; +pub const EXTERNAL_SESSION_IMPORT_ECOSYSTEM_CODEX: &str = "codex"; + +/// A removed local source reference. Restoring it does not copy or mutate the +/// external application's history, or any independently continued Session. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemovedExternalSession { + pub ecosystem_id: String, + pub session_id: String, + pub session_name: String, + pub workspace_id: String, + pub workspace_name: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RestoreRemovedExternalSessionRequest { + pub ecosystem_id: String, + pub target_workspace_id: String, + pub session_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSessionStatus { + Complete, + Interrupted, + Unsupported, + Corrupt, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSessionCandidate { + pub candidate_id: String, + pub ecosystem_id: String, + pub source_session_id: String, + pub title: Option, + /// Host-only previous prompt-derived title, used to recognize imported + /// defaults without overwriting a locally renamed Session. Keep this out + /// of the wire shape so older strict catalog readers remain compatible. + #[serde(skip)] + pub fallback_title: Option, + /// True only when the adapter read a saved source name, rather than a + /// rollout preview. A temporarily unavailable name store cannot rename + /// existing imports back to a prompt excerpt. + #[serde(skip)] + pub title_is_source_name: bool, + pub cwd: Option, + pub created_at: Option, + pub updated_at: Option, + pub model_provider: Option, + pub model_name: Option, + pub turn_count: Option, + pub file_size_bytes: u64, + pub format_version: Option, + pub status: ExternalSessionStatus, + pub fingerprint: String, + /// A display-only path label. The raw host path is never required from a + /// client in order to apply an import. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_location: Option, + /// The tracked local workspace that owns the source working directory. + /// This is resolved by the host, never inferred from the active UI + /// workspace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_workspace_name: Option, + /// Existing OpenBitFun session created from this source rollout, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub imported_session_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSessionImportWarning { + pub code: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSessionCatalog { + pub schema_version: u32, + pub ecosystem_id: String, + pub source_revision: String, + pub candidates: Vec, + pub diagnostics: Vec, + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSessionImportMode { + #[default] + Full, + LatestCompacted, +} + +/// Neutral, non-executable history supplied by an ecosystem reader. +#[derive(Clone, Serialize)] +pub struct ExternalSessionTurn { + pub source_turn_id: String, + pub timestamp: u64, + pub user_text: String, + pub items: Vec, + pub source_event_count: usize, + pub status: ExternalSessionStatus, +} + +#[derive(Clone, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ExternalSessionItem { + Text { + text: String, + }, + Thinking { + text: String, + }, + Tool { + id: String, + name: String, + input: serde_json::Value, + result: Option, + success: Option, + }, +} + +#[derive(Clone)] +pub struct ExternalSessionDocument { + pub candidate: ExternalSessionCandidate, + pub turns: Vec, + pub warnings: Vec, +} + +/// Compatibility name for the original Codex-only reader boundary. +pub type CodexSessionDocument = ExternalSessionDocument; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_flat_catalog_round_trips_without_workspace_pagination_fields() { + let legacy = serde_json::json!({ + "schemaVersion": 1, "ecosystemId": "codex", "sourceRevision": "v1", + "candidates": [], "diagnostics": [], "nextCursor": 50 + }); + let catalog: ExternalSessionCatalog = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(serde_json::to_value(catalog).unwrap(), legacy); + } + + #[test] + fn legacy_candidate_round_trips_without_workspace_annotations() { + let legacy = serde_json::json!({ + "candidateId": "codex:session", "ecosystemId": "codex", + "sourceSessionId": "session", "title": null, "cwd": "/project", + "createdAt": null, "updatedAt": null, "modelProvider": null, + "modelName": null, "turnCount": null, "fileSizeBytes": 120, + "formatVersion": null, "status": "complete", "fingerprint": "sha256:test" + }); + let mut candidate: ExternalSessionCandidate = + serde_json::from_value(legacy.clone()).unwrap(); + assert!(candidate.target_workspace_id.is_none()); + assert!(candidate.target_workspace_name.is_none()); + assert!(candidate.imported_session_id.is_none()); + assert!(candidate.fallback_title.is_none()); + assert!(!candidate.title_is_source_name); + candidate.fallback_title = Some("Previous prompt preview".into()); + candidate.title_is_source_name = true; + assert_eq!(serde_json::to_value(candidate).unwrap(), legacy); + } +} diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 97f810daed..d7a3dd7f26 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -95,6 +95,7 @@ cargo test -p openbitfun-services-core --no-default-features --features workspac cargo test -p openbitfun-services-core --no-default-features --features workspace-runtime --lib workspace::tests:: cargo test -p openbitfun-services-core --no-default-features --features local-storage --test session_contracts session_metadata_contracts:: cargo test -p openbitfun-services-core --no-default-features --features local-storage --lib session::metadata +cargo test -p openbitfun-services-core --no-default-features --features local-storage --lib session::offline_import cargo test -p openbitfun-services-core --no-default-features --features local-storage --test session_write_lock_contracts cargo test -p openbitfun-services-core --no-default-features --features local-storage --test exclusive_file_lease_contracts cargo test -p openbitfun-services-core --no-default-features --features memory-store --lib memory_store::tests:: diff --git a/src/crates/services/services-core/src/session/metadata_store.rs b/src/crates/services/services-core/src/session/metadata_store.rs index f413bd72c1..619fdd9764 100644 --- a/src/crates/services/services-core/src/session/metadata_store.rs +++ b/src/crates/services/services-core/src/session/metadata_store.rs @@ -344,6 +344,21 @@ impl SessionMetadataStore { self.write_json_atomic(&self.index_path(), &index).await } + /// Snapshot of the native navigation index without a directory scan. This + /// is also the source for merging lightweight external history references. + pub async fn list_indexed_metadata( + &self, + ) -> Result, SessionMetadataStoreError> { + if !self.sessions_root().exists() { + return Ok(Vec::new()); + } + let lock = self.get_index_lock().await; + let _guard = lock.lock().await; + let _file_guard = self.lock_index_file().await?; + let (index, _) = self.read_or_rebuild_index_locked().await?; + Ok(compatible_metadata_list(index.sessions)) + } + pub async fn list_metadata(&self) -> Result, SessionMetadataStoreError> { if !self.sessions_root().exists() { return Ok(Vec::new()); diff --git a/src/crates/services/services-core/src/session/offline_import.rs b/src/crates/services/services-core/src/session/offline_import.rs index 71f321fb16..2facaf5c35 100644 --- a/src/crates/services/services-core/src/session/offline_import.rs +++ b/src/crates/services/services-core/src/session/offline_import.rs @@ -11,16 +11,29 @@ use super::{ }; use crate::json_store::{JsonFileStore, JsonFileStoreError}; use openbitfun_core_types::validate_session_id; +use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use thiserror::Error; +use tokio::fs; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct OfflineSessionBundle { pub metadata: SessionMetadata, pub turns: Vec, } +#[derive(Serialize, Deserialize)] +struct BundleRepair { + schema_version: u32, + before: OfflineSessionBundle, + after: OfflineSessionBundle, +} + +fn same_record(left: &impl Serialize, right: &impl Serialize) -> bool { + matches!((serde_json::to_value(left), serde_json::to_value(right)), (Ok(left), Ok(right)) if left == right) +} + impl OfflineSessionBundle { pub fn validate(&self) -> Result<(), OfflineSessionImportError> { validate_bundle_shape(self) @@ -29,6 +42,8 @@ impl OfflineSessionBundle { #[derive(Debug, Error)] pub enum OfflineSessionImportError { + #[error("Session import cancelled")] + Cancelled, #[error(transparent)] Metadata(#[from] SessionMetadataStoreError), #[error(transparent)] @@ -79,6 +94,112 @@ impl OfflineSessionImportStore { Ok(()) } + /// Publishes a complete bundle only after every turn file has been written. + /// The metadata file is the visibility boundary used by session listing, so + /// a failed import cannot leave a half-populated session in the catalog. + pub async fn write_bundle_atomic( + &self, + bundle: &OfflineSessionBundle, + ) -> Result<(), OfflineSessionImportError> { + self.write_bundle_atomic_cancellable(bundle, || false).await + } + + /// Cancellation is honored until publication starts. Once published, the + /// complete Session remains authoritative even if a late cancel arrives. + pub async fn write_bundle_atomic_cancellable( + &self, + bundle: &OfflineSessionBundle, + is_cancelled: impl Fn() -> bool, + ) -> Result<(), OfflineSessionImportError> { + if is_cancelled() { + return Err(OfflineSessionImportError::Cancelled); + } + validate_bundle_shape(bundle)?; + fs::create_dir_all(self.sessions_root()) + .await + .map_err(|error| { + OfflineSessionImportError::InvalidBundle(format!( + "failed to create Sessions directory: {error}" + )) + })?; + + let session_id = &bundle.metadata.session_id; + let final_dir = self.layout.session_dir(session_id); + if fs::try_exists(&final_dir).await.map_err(|error| { + OfflineSessionImportError::InvalidBundle(format!( + "failed to inspect existing Session directory: {error}" + )) + })? { + return Err(OfflineSessionImportError::InvalidBundle(format!( + "Session {session_id} already exists" + ))); + } + + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let staging_root = self.sessions_root().join(format!( + ".importing-{session_id}-{nonce}-{}", + std::process::id() + )); + let staging_layout = SessionStorageLayout::new(&staging_root); + staging_layout + .ensure_turns_dir(session_id) + .await + .map_err(|error| { + OfflineSessionImportError::InvalidBundle(format!( + "failed to create staging Turns directory: {error}" + )) + })?; + + let mut published = false; + let write_result = async { + for turn in &bundle.turns { + if is_cancelled() { + return Err(OfflineSessionImportError::Cancelled); + } + self.json_store + .write_atomic_strict( + &staging_layout.turn_path(session_id, turn.turn_index), + &StoredDialogTurnFile::new(turn.clone()), + ) + .await?; + } + + if is_cancelled() { + return Err(OfflineSessionImportError::Cancelled); + } + fs::rename(staging_layout.session_dir(session_id), &final_dir) + .await + .map_err(|error| { + OfflineSessionImportError::InvalidBundle(format!( + "failed to publish Session directory: {error}" + )) + })?; + published = true; + self.metadata_store + .save_metadata(&bundle.metadata) + .await + .map_err(OfflineSessionImportError::Metadata) + } + .await; + + if let Err(error) = write_result { + let _ = fs::remove_dir_all(&staging_root).await; + // A competing importer may have published the same stable ID. + // Failure before our rename must never remove its completed data. + if published { + let _ = fs::remove_dir_all(&final_dir).await; + } + let _ = self.metadata_store.rebuild_index().await; + return Err(error); + } + + let _ = fs::remove_dir_all(&staging_root).await; + Ok(()) + } + pub async fn load_bundle( &self, session_id: &str, @@ -141,11 +262,174 @@ impl OfflineSessionImportStore { Ok(Some(bundle)) } + /// Repairs an existing import under the caller's Session writer lock. The + /// original bundle and complete target are retained before any replacement; + /// metadata is committed last. A retry resumes without reading the source. + pub async fn repair_bundle( + &self, + before: &OfflineSessionBundle, + after: &OfflineSessionBundle, + ) -> Result<(), OfflineSessionImportError> { + before.validate()?; + after.validate()?; + if before.metadata.session_id != after.metadata.session_id + || before.turns.iter().any(|turn| { + !after + .turns + .iter() + .any(|next| next.turn_index == turn.turn_index && next.turn_id == turn.turn_id) + }) + { + return Err(OfflineSessionImportError::InvalidBundle( + "A history repair cannot remove or reidentify existing Turns".into(), + )); + } + let id = &before.metadata.session_id; + let journal = self.layout.session_dir(id).join(".history-repair.json"); + if self + .json_store + .read_optional::(&journal) + .await? + .is_some() + { + return Err(OfflineSessionImportError::InvalidBundle( + "A history repair already exists; resume it before starting another".into(), + )); + } + let current = self.load_bundle(id).await?.ok_or_else(|| { + OfflineSessionImportError::InvalidBundle("The Session to repair is missing".into()) + })?; + if !same_record(¤t, before) { + return Err(OfflineSessionImportError::InvalidBundle( + "The Session changed before history repair; preserving its current content".into(), + )); + } + self.json_store + .write_atomic_strict( + &journal, + &BundleRepair { + schema_version: 1, + before: before.clone(), + after: after.clone(), + }, + ) + .await?; + self.resume_bundle_repair(id).await?; + Ok(()) + } + + /// Returns false when no repair journal exists. Never overwrite a Turn or + /// metadata edited since the journal was written, including by older hosts. + pub async fn resume_bundle_repair(&self, id: &str) -> Result { + validate_session_id(id).map_err(OfflineSessionImportError::InvalidBundle)?; + let Some(repair) = self + .json_store + .read_optional::( + &self.layout.session_dir(id).join(".history-repair.json"), + ) + .await? + else { + return Ok(false); + }; + repair.before.validate()?; + repair.after.validate()?; + if repair.schema_version != 1 + || repair.before.metadata.session_id != id + || repair.after.metadata.session_id != id + || repair.before.turns.iter().any(|turn| { + !repair + .after + .turns + .iter() + .any(|next| next.turn_index == turn.turn_index && next.turn_id == turn.turn_id) + }) + { + return Err(OfflineSessionImportError::InvalidBundle( + "Unsupported or mismatched history repair journal; original content is retained" + .into(), + )); + } + let current = self + .json_store + .read_optional::(&self.layout.metadata_path(id)) + .await? + .ok_or_else(|| { + OfflineSessionImportError::InvalidBundle( + "The Session being repaired is missing".into(), + ) + })?; + if current.schema_version > SESSION_STORAGE_SCHEMA_VERSION + || (!same_record(¤t.metadata, &repair.before.metadata) + && !same_record(¤t.metadata, &repair.after.metadata)) + { + return Err(OfflineSessionImportError::InvalidBundle( + "Session metadata changed during history repair; original content is retained" + .into(), + )); + } + let paths = self + .layout + .list_indexed_turn_paths(id) + .await + .map_err(|error| OfflineSessionImportError::InvalidBundle(error.to_string()))?; + for (index, path) in &paths { + let current = self + .json_store + .read_optional::(path) + .await? + .ok_or_else(|| { + OfflineSessionImportError::InvalidBundle( + "A Turn disappeared during history repair".into(), + ) + })?; + let matches = |bundle: &OfflineSessionBundle| { + bundle + .turns + .iter() + .any(|turn| turn.turn_index == *index && same_record(turn, ¤t.turn)) + }; + if current.schema_version > SESSION_STORAGE_SCHEMA_VERSION + || (!matches(&repair.before) && !matches(&repair.after)) + { + return Err(OfflineSessionImportError::InvalidBundle("Session history changed during repair; original and current content are retained".into())); + } + } + if repair + .before + .turns + .iter() + .any(|turn| !paths.iter().any(|(index, _)| *index == turn.turn_index)) + { + return Err(OfflineSessionImportError::InvalidBundle("An original Turn is missing during history repair; refusing to recreate deleted history".into())); + } + for turn in &repair.after.turns { + self.json_store + .write_atomic_strict( + &self.layout.turn_path(id, turn.turn_index), + &StoredDialogTurnFile::new(turn.clone()), + ) + .await?; + } + self.metadata_store + .save_metadata(&repair.after.metadata) + .await?; + Ok(true) + } + pub async fn rebuild_index(&self) -> Result<(), OfflineSessionImportError> { self.metadata_store.rebuild_index().await?; Ok(()) } + pub async fn list_metadata_including_internal( + &self, + ) -> Result, OfflineSessionImportError> { + Ok(self + .metadata_store + .list_metadata_including_internal() + .await?) + } + pub fn sessions_root(&self) -> &Path { self.layout.sessions_root() } @@ -192,6 +476,189 @@ mod tests { use super::*; use std::fs; + fn legacy_repair_bundles() -> (OfflineSessionBundle, OfflineSessionBundle) { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1"); + let metadata: StoredSessionMetadataFile = + serde_json::from_slice(&fs::read(fixture.join("metadata.json")).unwrap()).unwrap(); + let turn: StoredDialogTurnFile = + serde_json::from_slice(&fs::read(fixture.join("turns/turn-0000.json")).unwrap()) + .unwrap(); + let before = OfflineSessionBundle { + metadata: metadata.metadata, + turns: vec![turn.turn], + }; + let mut after = before.clone(); + after.turns[0].user_message.content.push_str(" restored"); + let mut second = after.turns[0].clone(); + second.turn_index = 1; + second.turn_id = "restored-turn-2".into(); + after.turns.push(second); + after.metadata.turn_count = 2; + after.metadata.message_count += 1; + (before, after) + } + + #[tokio::test] + async fn history_repair_retains_the_original_and_resumes_partial_writes() { + let (before, after) = legacy_repair_bundles(); + let temp = tempfile::tempdir().unwrap(); + let store = OfflineSessionImportStore::new(temp.path()); + store.write_bundle_atomic(&before).await.unwrap(); + let id = &before.metadata.session_id; + let journal_path = store.layout.session_dir(id).join(".history-repair.json"); + store + .json_store + .write_atomic_strict( + &journal_path, + &BundleRepair { + schema_version: 1, + before: before.clone(), + after: after.clone(), + }, + ) + .await + .unwrap(); + // Simulate interruption after one replacement and one append, before + // the metadata commit. Recovery needs no original external source. + for turn in &after.turns { + store + .json_store + .write_atomic_strict( + &store.layout.turn_path(id, turn.turn_index), + &StoredDialogTurnFile::new(turn.clone()), + ) + .await + .unwrap(); + } + assert!(store.load_bundle(id).await.is_err()); + let restarted = OfflineSessionImportStore::new(temp.path()); + assert!(restarted.resume_bundle_repair(id).await.unwrap()); + assert!(same_record( + &restarted.load_bundle(id).await.unwrap().unwrap(), + &after + )); + let journal: BundleRepair = restarted + .json_store + .read_optional(&journal_path) + .await + .unwrap() + .unwrap(); + assert!(same_record(&journal.before, &before)); + assert!(restarted.resume_bundle_repair(id).await.unwrap()); + assert!(same_record( + &restarted.load_bundle(id).await.unwrap().unwrap(), + &after + )); + } + + #[tokio::test] + async fn history_repair_refuses_changes_made_after_preparation() { + for edit_metadata in [false, true] { + let (before, after) = legacy_repair_bundles(); + let temp = tempfile::tempdir().unwrap(); + let store = OfflineSessionImportStore::new(temp.path()); + store.write_bundle_atomic(&before).await.unwrap(); + let id = &before.metadata.session_id; + store + .json_store + .write_atomic_strict( + &store.layout.session_dir(id).join(".history-repair.json"), + &BundleRepair { + schema_version: 1, + before: before.clone(), + after, + }, + ) + .await + .unwrap(); + let mut edited = before.clone(); + if edit_metadata { + edited.metadata.session_name = "User edit after interruption".into(); + } else { + edited.turns[0].user_message.content = "User edit after interruption".into(); + } + store.write_bundle(&edited).await.unwrap(); + assert!(store.resume_bundle_repair(id).await.is_err()); + assert!(same_record( + &store.load_bundle(id).await.unwrap().unwrap(), + &edited + )); + } + } + + #[tokio::test] + async fn failed_publication_never_removes_another_importers_session() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let temp = tempfile::tempdir().unwrap(); + let store = OfflineSessionImportStore::new(temp.path()); + let bundle = OfflineSessionBundle { + metadata: SessionMetadata::new( + "same-source".into(), + "Imported".into(), + "primary".into(), + "primary".into(), + ), + turns: vec![], + }; + let final_dir = temp.path().join("same-source"); + let checkpoints = AtomicUsize::new(0); + let result = store + .write_bundle_atomic_cancellable(&bundle, || { + // Another process publishes after the existence check, before our rename. + if checkpoints.fetch_add(1, Ordering::SeqCst) == 1 { + fs::create_dir_all(&final_dir).unwrap(); + fs::write(final_dir.join("other-process-history"), b"preserve me").unwrap(); + } + false + }) + .await; + assert!(result.is_err()); + assert_eq!( + fs::read(final_dir.join("other-process-history")).unwrap(), + b"preserve me" + ); + } + + #[tokio::test] + async fn cancelled_atomic_import_cleans_staging_without_publishing_or_removing_existing_data() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + "../legacy-migration/tests/fixtures/v0.2.19/home/projects/c--fixture-workspace/sessions/session-1", + ); + let stored_metadata: StoredSessionMetadataFile = + serde_json::from_slice(&fs::read(fixture.join("metadata.json")).unwrap()).unwrap(); + let stored_turn: StoredDialogTurnFile = + serde_json::from_slice(&fs::read(fixture.join("turns/turn-0000.json")).unwrap()) + .unwrap(); + let bundle = OfflineSessionBundle { + metadata: stored_metadata.metadata, + turns: vec![stored_turn.turn], + }; + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("existing-data"), b"preserved").unwrap(); + let store = OfflineSessionImportStore::new(temp.path()); + let checkpoints = AtomicUsize::new(0); + + let result = store + .write_bundle_atomic_cancellable(&bundle, || { + checkpoints.fetch_add(1, Ordering::SeqCst) >= 2 + }) + .await; + + assert!(matches!(result, Err(OfflineSessionImportError::Cancelled))); + assert_eq!(checkpoints.load(Ordering::SeqCst), 3); + assert!(store.load_bundle("session-1").await.unwrap().is_none()); + assert_eq!( + fs::read(temp.path().join("existing-data")).unwrap(), + b"preserved" + ); + assert!(!fs::read_dir(temp.path()).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".importing-"))); + } + #[tokio::test] async fn owner_writer_round_trips_a_legacy_bundle_in_the_current_format() { let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( @@ -216,7 +683,7 @@ mod tests { .unwrap(); let store = OfflineSessionImportStore::new(temp.path()); - store.write_bundle(&bundle).await.unwrap(); + store.write_bundle_atomic(&bundle).await.unwrap(); let reloaded = store.load_bundle("session-1").await.unwrap().unwrap(); reloaded.validate().unwrap(); assert_eq!(reloaded.metadata.session_id, "session-1"); diff --git a/src/crates/services/services-core/src/session/page.rs b/src/crates/services/services-core/src/session/page.rs index 5ea3dff5ce..ec15477cdc 100644 --- a/src/crates/services/services-core/src/session/page.rs +++ b/src/crates/services/services-core/src/session/page.rs @@ -17,6 +17,11 @@ pub struct SessionMetadataPage { /// array is an authoritative empty status batch, not a missing capability. #[serde(default, skip_serializing_if = "Option::is_none")] pub activities: Option>, + /// Non-fatal host discovery issues. Native history remains available. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub discovery_warnings: Vec, + #[serde(default, skip_serializing_if = "is_false")] + pub discovery_pending: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -51,6 +56,8 @@ pub fn empty_session_metadata_page() -> SessionMetadataPage { next_cursor: None, has_more: false, activities: None, + discovery_warnings: Vec::new(), + discovery_pending: false, } } @@ -121,6 +128,8 @@ pub fn build_session_metadata_page( next_cursor, has_more, activities: None, + discovery_warnings: Vec::new(), + discovery_pending: false, } } @@ -149,6 +158,10 @@ fn session_parent_id(metadata: &SessionMetadata) -> Option { .map(ToOwned::to_owned) } +fn is_false(value: &bool) -> bool { + !value +} + fn session_metadata_page_offset( cursor: Option<&str>, top_level_sessions: &[SessionMetadata], @@ -183,3 +196,16 @@ fn session_metadata_page_cursor(metadata: &SessionMetadata) -> String { }) .unwrap_or_else(|_| metadata.session_id.clone()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_page_round_trips_without_discovery_fields() { + let legacy = serde_json::json!({"sessions": [], "totalTopLevelCount": 0, "loadedTopLevelCount": 0, "hasMore": false}); + let page: SessionMetadataPage = serde_json::from_value(legacy.clone()).unwrap(); + assert!(page.discovery_warnings.is_empty()); + assert_eq!(serde_json::to_value(page).unwrap(), legacy); + } +} diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index ec31286754..6f30a334ac 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -3861,8 +3861,8 @@ "categoryId": "automation", "titleZh": "外部导入", "titleEn": "External Import", - "summaryZh": "查看五类外部 AI 生态的内容与状态,导入受支持的 Skill、MCP 和 Hook 副本,并查看 Command、Tool、子智能体的兼容使用状态。", - "summaryEn": "Inspect content and status from five external AI ecosystems, import supported Skill, MCP and Hook copies, and review compatibility usage for commands, tools and subagents.", + "summaryZh": "查看外部 AI 生态的内容与状态,导入受支持的 Skill、MCP、Hook,以及 Codex、Zcode 会话,并在 OpenBitFun 中继续对话。", + "summaryEn": "Inspect external AI content and status, import supported Skills, MCP, Hooks, and Codex or Zcode sessions, and continue conversations in OpenBitFun.", "keywordsZh": [ "外部导入", "生态兼容", @@ -3871,7 +3871,10 @@ "OpenCode", "Claude Code", "Codex", - "导入" + "导入", + "Zcode", + "智谱", + "会话导入" ], "keywordsEn": [ "external import", @@ -3882,19 +3885,24 @@ "OpenCode", "Claude Code", "Codex", - "import" + "import", + "Zcode", + "Z.ai", + "session import" ], "highlightsZh": [ "按已识别与更多应用浏览;可发现表示支持查找,分类状态表示当前结果", "自动发现只控制列表更新;关闭后保留结果,仍可手动刷新,已接入的功能照常运行", "审阅并导入受支持的 Skill、MCP 与 Hook;Command、Tool、子智能体暂无副本导入", - "已导入表示副本已保存;在对应管理页确认启用与连接" + "已导入表示副本已保存;在对应管理页确认启用与连接", + "Codex、Zcode 会话按工作区自动发现;打开来源时刷新历史,首次发送创建独立分支,可移除和重新添加来源" ], "highlightsEn": [ "Browse identified and more apps; discovery coverage and current category results are separate", "Automatic discovery controls list updates; pausing retains results and allows manual refresh without changing active integrations", "Review and import supported Skills, MCP and Hooks; command, tool and subagent copy import is unavailable", - "Imported means a copy was saved; confirm activation and connection in the relevant settings" + "Imported means a copy was saved; confirm activation and connection in the relevant settings", + "Discover Codex and Zcode sessions by workspace, refresh history on open, continue in a new branch on first send, and remove or restore source views" ], "items": [ { @@ -3931,6 +3939,40 @@ "command:set_external_source_conflict_choice_command" ] }, + { + "id": "codex-session-import", + "titleZh": "发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊", + "titleEn": "Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“发现、移除和重新添加 Codex 来源会话,并在 OpenBitFun 分支中续聊”由用户在生态兼容页开启发现或选择已移除的来源。打开来源时刷新历史,首次发送创建独立分支。归档、移除和分支删除各自独立,原应用历史保持不变。智能体打开入口,由用户选择具体会话。", + "reasonEn": "“Discover, remove and restore Codex source sessions, then continue in OpenBitFun branches” requires the user to enable discovery or select a removed source in ecosystem compatibility. Opening a source refreshes its history and first send creates an independent branch. Archives, source removal and branch deletion stay independent; original history is preserved. The Agent opens the entry for the user to choose a session." + }, + "evidence": [ + "command:list_removed_external_sessions", + "command:restore_removed_external_session", + "source:src/web-ui/src/app/scenes/ecosystem-compatibility/RemovedExternalSessions.tsx#RemovedExternalSessions", + "source:src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentSessions.tsx#ExternalAgentSessions" + ] + }, + { + "id": "zcode-session-import", + "titleZh": "发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊", + "titleEn": "Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "“发现、移除和重新添加 Zcode 来源会话,并在 OpenBitFun 分支中续聊”由用户在生态兼容页开启发现或选择已移除的来源。打开来源时刷新历史,首次发送创建独立分支。归档、移除和分支删除各自独立,原应用历史保持不变。智能体打开入口,由用户选择具体会话。", + "reasonEn": "“Discover, remove and restore Zcode source sessions, then continue in OpenBitFun branches” requires the user to enable discovery or select a removed source in ecosystem compatibility. Opening a source refreshes its history and first send creates an independent branch. Archives, source removal and branch deletion stay independent; original history is preserved. The Agent opens the entry for the user to choose a session." + }, + "evidence": [ + "command:list_removed_external_sessions", + "command:restore_removed_external_session", + "source:src/web-ui/src/app/scenes/ecosystem-compatibility/RemovedExternalSessions.tsx#RemovedExternalSessions", + "source:src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentSessions.tsx#ExternalAgentSessions" + ] + }, { "id": "prompts-commands", "titleZh": "展开外部 Prompt/Command 内容并处理与原生命令的冲突", @@ -9505,6 +9547,9 @@ "external_sources": { "capabilityId": "feature.ecosystem-compatibility" }, + "external_session_import": { + "capabilityId": "feature.ecosystem-compatibility" + }, "snapshot_service": { "capabilityId": "feature.files-editor" }, diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 09238cd880..5e1f7598c5 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -138,6 +138,15 @@ For ecosystem discovery, import, or compatibility status presentation changes: pnpm --dir src/web-ui run test:run src/app/scenes/ecosystem-compatibility ``` +For followed external session views, refresh-on-open and first-send forks: + +```bash +pnpm --dir src/web-ui run test:run src/flow_chat/components/ExternalHistoryNotice.test.tsx src/flow_chat/utils/externalHistory.test.ts src/flow_chat/services/flow-chat-manager/MessageModule.test.ts src/flow_chat/services/flow-chat-manager/SessionModule.test.ts src/flow_chat/store/FlowChatStore.test.ts src/flow_chat/components/modern/UserMessageItem.test.tsx src/flow_chat/hooks/useMessageSender.test.tsx +``` + +These local store/DOM fixtures cover source-version pinning, device switches and +explicit Peer gating; they do not validate native WebView or remote-host behavior. + For application update discovery, skip persistence, notification timing, and download/install transitions, run the focused behavior tests (these do not establish visual fidelity): diff --git a/src/web-ui/README.md b/src/web-ui/README.md index 28e0503f21..975ba1b4f2 100644 --- a/src/web-ui/README.md +++ b/src/web-ui/README.md @@ -291,8 +291,61 @@ preserve omitted source fields and existing app storage when updating. Open a disposable local workspace in Desktop and select an app under External Import. Use test content or reversible imported copies. Category discovery coverage is independent of copy import and execution support. +For Codex and Zcode, enable **Automatically discover sessions** in Ecosystem +Compatibility. Opening a local workspace adds a lightweight index of all matching +source sessions to its regular list, including startup-restored workspace lists. A +small source label identifies imported history. Use **Session source** in the +workspace menu to show all agents or only OpenBitFun, Codex or Zcode. Filtering +searches the complete metadata catalog, including sessions beyond the first page, +while the workspace list initially shows five sessions. Use its existing expand +controls to see more; switching sources starts folded again. +Preferences are stored on the serving host; workspace filters are scoped by device +and workspace. Older hosts display an unavailable switch. SSH workspace discovery +is explicitly unsupported and never reads controller-side history. +Opening a source session caches its history and keeps following the original Agent. +The source refreshes once each time it is opened. Staying on the page, typing, +returning to the window or viewing a native branch does not refresh it. +The first send creates an independent OpenBitFun branch from the version you have +viewed. The source keeps updating separately; later source messages are never merged +into that branch. Edit or roll back source history after explicitly forking it. +Startup and list operations do not copy transcript bodies. A missing source keeps +its last cache, or reports a retryable error if it has never been opened. Renames, +archives and deletions survive rediscovery. Turning discovery off keeps existing +entries and copies. Old automatic imports keep their original row and ID. Opening +validates their retained local history: untouched copies follow the source, while +local continuations or edits remain independent. Manual imports remain independent. +Permission choices in a source composer apply to the branch created on first send. +Use **Remove from OpenBitFun** to hide a source without deleting its original history +or any continuation branch. It stays removed across scans. In the source's ecosystem +compatibility settings, **Removed source sessions → Add again** restores the same +entry and its saved name. A branch whose source was removed also offers this action +when you choose **Open source session**. Re-adding reads history only when opened. +Source and branch archives/deletions are independent. Restore archived entries from +the existing archived sessions page; new source messages do not unarchive them. +Failed delete requests retain the affected session and its draft for retry. +Peer mode explicitly disables following source views until its history stream can +pin their versions; existing native branches retain their normal Peer behavior. +Removed source management is also gated in Peer mode; use the source's owning host. +Other clients without automatic source forking receive a read-only error on direct submission. +Names use the original Agent's saved title. Codex reads its name index and local +state database; Zcode uses the saved task title. Rediscovery corrects old prompt-preview +names and refreshes source names while preserving names edited in OpenBitFun. +Source views are excluded from workspace full-text search with a diagnostic; +native branches can be searched. New branches use the host's configured model. +Zcode reads saved top-level tasks from `~/.zcode/cli/db/db.sqlite`, including committed +WAL data. Hidden inherited context and nested side/subagent tasks are excluded. +Source data stays intact; attachment names are retained but attachment files are not copied. +History discovery does not enable Zcode runtime, configuration, or plugin compatibility. + +Imported tool history reuses OpenBitFun's existing tool cards by matching names +and equivalent operations. Programs, commands, file operations, searches and plans +use their native cards; unmatched tools use the standard generic card. Existing +imports receive this display when reopened. Original records remain unchanged, +and imported cards cannot approve tools or act on current-workspace snapshots. + | Check | Action | Expected result | |---|---|---| +| Automatic session discovery | Enable Codex or Zcode session discovery, reopen a matching workspace, then continue its source in both apps | Discovery indexes metadata only; opening caches a followed view. First send forks the viewed version; later source changes stay separate. Drafts, rename/archive/delete, old native copies and missing-source caches remain intact | | Command discovery | Put `Reply with STATUS_OK.` in `.claude/commands/status-check.md`, then refresh the catalog | The category shows the discovered count and the category dialog lists the discovered commands. Viewing it neither enables execution nor offers unsupported copy import; runtime activation stays with its existing owner | | MCP copy versus connection | Review and import an existing supported MCP declaration, leaving the native copy disabled | The row says Imported and connection not confirmed. Check and enable the copy in MCP settings, which owns the actual connection result | | Read-only hooks | Select Pi or DSH with a configured extension and open the Hook category dialog | Static declarations are viewable without execution. Single and category import actions are absent | diff --git a/src/web-ui/README.zh-CN.md b/src/web-ui/README.zh-CN.md index aba196fd96..2113cfceb7 100644 --- a/src/web-ui/README.zh-CN.md +++ b/src/web-ui/README.zh-CN.md @@ -212,8 +212,19 @@ API Key 模型继续使用原有请求配置。 在本地 Desktop 打开一个临时工作区,从“外部导入”选择对应应用。只使用测试内容或可撤销的副本。 类别的“可发现”表示已接入受支持格式;“暂未支持”及其说明仅表示暂时无法在此页查看该类别内容,不代表产品其他入口不支持该能力。 +Codex 和 Zcode 分别提供会话自动发现开关,默认关闭。开启后,打开工作区会在服务主机后台为归属于该工作区的所有受支持会话建立轻量索引。 +启动时恢复的工作区也会在首次读取会话列表时发现。会话带来源标签;每次打开来源会话时刷新一次,停留、输入、恢复窗口可见及查看分支都不会自动刷新。首次发送从已显示的版本创建 OpenBitFun 原生分支,使用当前主机配置的模型和输入区选择的权限续聊。旧版自动导入复用原条目和 ID,打开时核对并保留已有本地续聊或编辑;未编辑的副本继续跟随来源,不额外显示第二条。来源后续消息不会自动并入分支;编辑或回退来源历史前须先显式分叉。 +工作区菜单的“会话来源”可筛选 OpenBitFun、Codex 或 Zcode;筛选覆盖全部元数据分页,但工作区列表仍先显示前五个会话,通过原有展开按钮查看更多,切换来源后重新折叠。 +源会话菜单使用“从 OpenBitFun 移除”,保留原应用历史和已有续聊分支,重新扫描不会让条目再次出现。在对应来源的生态兼容设置中,通过“已移除的源会话 → 重新添加”恢复原条目、名称和缓存;分支中查看已移除的来源时也会提供此操作。重新添加不读取正文,打开来源时再刷新。源会话和续聊分支的删除、归档互不影响,来源新消息不会自动取消本地归档;归档会话通过原归档管理页恢复。删除失败保留会话和草稿。Peer 模式禁用来源重新添加,请在会话所属主机操作。 +启动和列表操作不复制历史正文。来源不可读时保留最近缓存;没有缓存则明确报错并保留重试条目。改名、归档和删除状态在重新发现后保留;关闭开关不删除已有条目和副本。既有原生副本保持独立,来源另建跟随视图。来源视图暂不参与工作区正文搜索,响应提供说明;原生分支可参与搜索。 +Peer 的历史流尚不能固定来源版本,因此明确禁用来源视图的跟随与直接续聊;已有原生分支沿用原 Peer 路径。未提供自动分叉的其他客户端直接提交来源会话时会收到明确的只读错误。 +会话名称优先使用原 Agent 保存的标题:Codex 读取名称索引与本地状态数据库,Zcode 使用已保存的任务标题。重新发现会修正旧的首条消息摘要名称并更新来源名称,保留在 OpenBitFun 中手动改过的名称。 +Zcode 读取 `~/.zcode/cli/db/db.sqlite` 保存的主任务,包括已提交的 WAL 数据;隐藏的继承上下文、侧聊和子任务不作为额外会话导入。 +来源数据保持不变;附件保留名称引用,不复制附件文件。发现读取服务主机的本地历史,SSH 工作区暂不支持自动发现。旧主机缺少该能力时开关只读;Zcode 运行时、配置与插件兼容按各自真实状态显示。 + | 检查 | 操作 | 预期 | |---|---|---| +| 会话自动发现 | 开启 Codex 或 Zcode 的会话自动发现,再打开匹配工作区,并在两个应用中继续该来源 | 发现仅建元数据索引;打开缓存跟随视图,首次发送分叉已显示版本;来源后续内容保持独立。草稿、改名、归档、删除、旧副本和来源丢失后的缓存均保留 | | Command 发现 | 在临时项目的 `.claude/commands/status-check.md` 写入 `Reply with STATUS_OK.`,然后刷新目录 | 类别显示发现数量,打开分类弹窗后可查看命令来源。查看不会启用执行,也不提供尚未支持的副本导入;运行启用仍由原有归属模块负责 | | MCP 保存与连接 | 选择已有的受支持 MCP 声明,审阅后导入;不要启用副本 | 显示“已导入”,说明连接状态尚未确认。去 MCP 管理页可看到禁用副本;启用后的真实连接结果在 MCP 管理页检查 | | Hook 只读子集 | 选择已配置扩展的 Pi 或 DSH,刷新并打开 Hook 分类弹窗 | 已发现项显示静态查看限制;无单项或分类导入按钮,查看不会执行声明 | diff --git a/src/web-ui/public/assets/ecosystem-compatibility/README.md b/src/web-ui/public/assets/ecosystem-compatibility/README.md index fd7148ba22..e7953f6cd1 100644 --- a/src/web-ui/public/assets/ecosystem-compatibility/README.md +++ b/src/web-ui/public/assets/ecosystem-compatibility/README.md @@ -6,6 +6,7 @@ These product marks are cached locally so the compatibility scene does not depen | --- | --- | | `claude-code.svg` | Claude official site: https://claude.ai/favicon.svg | | `codex.svg` | OpenAI Developers: https://developers.openai.com/favicon.svg | +| `zcode.png` | Zcode official site: https://zcode.z.ai/favicon-192x192.png, retrieved 2026-10-13 | | `pi.svg` | Pi official site: https://pi.dev/favicon.svg | | `deepseek-harness.svg` | DeepSeek Harness official repository: https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/website/public/favicon.svg | | `opencode.svg` | OpenCode official repository: https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/identity/mark.svg | diff --git a/src/web-ui/public/assets/ecosystem-compatibility/zcode.png b/src/web-ui/public/assets/ecosystem-compatibility/zcode.png new file mode 100644 index 0000000000..78cfb2ce55 Binary files /dev/null and b/src/web-ui/public/assets/ecosystem-compatibility/zcode.png differ diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 787ab9e854..61b25beb2f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -286,7 +286,8 @@ } } - &__inline-item-btw-badge { + &__inline-item-btw-badge, + &__inline-item-source-badge { flex: 0 0 auto; } diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 449d46da79..31b92c8fee 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -12,7 +12,7 @@ import { canCopySessionsOnSurface } from '@/infrastructure/peer-device/peerCapab */ import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import { subscribeOverlayInteraction, createOverlayPortal, Button, Icon, IconButton, Input, Menu, MenuItem, OverflowText, StatusPill, Tooltip } from '@openbitfun/ui'; +import { subscribeOverlayInteraction, createOverlayPortal, Alert, Button, Icon, IconButton, Input, Menu, MenuItem, OverflowText, StatusPill, Tooltip } from '@openbitfun/ui'; import { Loader2, Archive, FolderGit2, FolderOpen, ListChecks } from 'lucide-react'; import { RetainedMountBoundary } from '@/shared/presence'; import { useI18n } from '@/infrastructure/i18n'; @@ -24,7 +24,6 @@ import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext' import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import { createLogger } from '@/shared/utils/logger'; import { isSamePath } from '@/shared/utils/pathUtils'; -import { isLinkedWorktreeWorkspace } from '@/shared/types/global-state'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { @@ -48,6 +47,8 @@ import { DEFAULT_WORKSPACE_SESSION_FILTERS, hasWorkspaceSessionFilters, matchesWorkspaceSessionView, + matchesWorkspaceSourceAgent, + sessionSourceAgentLabel, useWorkspaceSessionViewStore, } from '../../workspaceSessionView'; import { stateMachineManager } from '@/flow_chat/state-machine'; @@ -57,7 +58,11 @@ import { isDefaultSessionTitle, resolveSessionTitle } from '@/flow_chat/utils/se import { useSessionTitleNumbers } from '@/flow_chat/hooks/useSessionTitleNumbers'; import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; import { useSessionComposerStore } from '@/flow_chat/store/sessionComposerStore'; -import { getActiveSurfaceId, isSurfaceChangedError, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; +import { getActiveSurfaceId, getActiveSurfaceScope, isSurfaceChangedError, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; +import { getWorkspaceSessionHierarchy } from './sessionNavHierarchy'; +import { useWorkspaceSessionCatalog } from './useWorkspaceSessionCatalog'; +import { useWorkspaceSessionDiscovery } from './useWorkspaceSessionDiscovery'; +import { useSessionNavExpansion } from './useSessionNavExpansion'; import { isSessionNavRowActive } from './sessionNavSelection'; import { isSessionRowPointerTarget } from './sessionOpenPointer'; import { @@ -101,8 +106,6 @@ import { hasStartupOverlayHandedOff, } from './sessionMetadataStartup'; import { - getEffectiveTopLevelSessionCount, - getSessionBufferPrefetchLimit, getSessionDisplayLimit, getSessionExpandToggleState, SESSIONS_LEVEL_0, @@ -122,12 +125,6 @@ type HistoryOpenIntentDispatchResult = 'none' | 'dispatched' | 'already-pending' /** Page size for the fully-expanded (level 2) session list. */ const SESSIONS_LEVEL_2_PAGE = 200; -/** - * Delay before topping the off-screen row buffer back up. Keeps the refill out - * of the startup burst and coalesces the repeated deletes of a cleanup pass. - */ -const SESSIONS_BUFFER_PREFETCH_DELAY_MS = 800; - const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); @@ -143,24 +140,6 @@ function DefaultSessionTitlePreview({ sessionId, createdAt }: Pick, workspaceId?: string, -): number => { - const scopedSessions = Array.from(sessions).filter((session: Session) => { - if (session.isTransient || session.sessionKind === 'subagent') return false; - return workspaceId ? sessionBelongsToWorkspaceNavRow(session, workspaceId) : !session.workspaceId; - }); - - const knownIds = new Set(scopedSessions.map(session => session.sessionId)); - return scopedSessions.reduce((count, session) => { - const parentSessionId = resolveSessionRelationship(session).parentSessionId; - if (parentSessionId && knownIds.has(parentSessionId)) { - return count; - } - return count + 1; - }, 0); -}; - const getChildSessionBadge = (kind: Session['sessionKind']): string => { const normalizedKind = kind === 'review' || kind === 'deep_review' || kind === 'subagent' @@ -250,12 +229,21 @@ const SessionsSection: React.FC = ({ const storedSessionOrdering = useWorkspaceSessionViewStore(state => state.ordering); const storedSessionShow = useWorkspaceSessionViewStore(state => state.show); const storedSessionFilters = useWorkspaceSessionViewStore(state => state.filters); + const sourceAgents = useWorkspaceSessionViewStore(state => state.sourceAgents); + const sourceAgent = sourceAgents[surfaceScopedKey(getActiveSurfaceId(), workspaceId)] ?? 'all'; + const hasSourceFilter = sourceAgent !== 'all' || Boolean(workspaceScopes?.some(scope => + (sourceAgents[surfaceScopedKey(getActiveSurfaceId(), scope.workspaceId)] ?? 'all') !== 'all')); + const matchesSourceAgent = useCallback((session: Session) => matchesWorkspaceSourceAgent( + session, + sourceAgents[surfaceScopedKey(getActiveSurfaceId(), workspaceId ?? session.workspaceId ?? session.config.workspaceId)] ?? 'all', + ), [sourceAgents, workspaceId]); const sessionOrdering = useWorkspaceViewPreferences ? storedSessionOrdering : 'created'; const sessionShow = useWorkspaceViewPreferences ? storedSessionShow : 'all'; const sessionFilters = useWorkspaceViewPreferences ? storedSessionFilters : DEFAULT_WORKSPACE_SESSION_FILTERS; - const hasActiveSessionFilter = sessionShow !== 'all' || hasWorkspaceSessionFilters(sessionFilters); + const hasWorkspaceViewFilter = sessionShow !== 'all' || hasWorkspaceSessionFilters(sessionFilters); + const hasActiveSessionFilter = hasSourceFilter || hasWorkspaceViewFilter; const showAllWithoutLimit = layout === 'flat' && Boolean(workspaceScopes?.length); const sessionListClassName = `openbitfun-nav-panel__inline-list${layout === 'flat' ? ' is-flat-workspace-view' : ''}`; const { setActiveWorkspace, openWorkspace, openedWorkspacesList, currentWorkspace } = useWorkspaceContext(); @@ -276,28 +264,18 @@ const SessionsSection: React.FC = ({ const dispatchTransportByJobId = useDispatchJobStore(state => state.transportByJobId); const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); - const [expandLevel, setExpandLevel] = useState<0 | 1 | 2>(0); - // Grouped workspace history keeps its existing level-2 paging. The flat - // aggregate projection bypasses this display cap and streams every page in. - const [level2DisplayCount, setLevel2DisplayCount] = useState(SESSIONS_LEVEL_2_PAGE); - - useEffect(() => { - if (expandLevel !== 2) { - setLevel2DisplayCount(SESSIONS_LEVEL_2_PAGE); - } - }, [expandLevel]); + const { expandLevel, level2DisplayCount, setExpandLevel, setLevel2DisplayCount } = useSessionNavExpansion( + `${surfaceScopedKey(getActiveSurfaceId(), workspaceId)}:${sourceAgent}`, + SESSIONS_LEVEL_2_PAGE, + ); + const surface = getActiveSurfaceScope(); + const initialMetadataKey = surface.key(workspaceId, surface.epoch); const [metadataPageState, setMetadataPageState] = useState<{ - totalTopLevelCount: number | null; - syncedTopLevelCount: number | null; - nextCursor?: string; - hasMore: boolean; + loadedScope: string | null; isLoading: boolean; loadError: boolean; }>({ - totalTopLevelCount: null, - syncedTopLevelCount: null, - nextCursor: undefined, - hasMore: false, + loadedScope: null, isLoading: false, loadError: false, }); @@ -309,6 +287,23 @@ const SessionsSection: React.FC = ({ failedScopeCount: 0, })); const [aggregateReloadRequestId, setAggregateReloadRequestId] = useState(0); + const [catalogRefreshVersion, setCatalogRefreshVersion] = useState(0); + const onDiscoveryCompleted = useCallback(() => { + setCatalogRefreshVersion(value => value + 1); + setAggregateReloadRequestId(value => value + 1); + }, []); + const discovery = useWorkspaceSessionDiscovery( + workspaceScopes?.map(scope => scope.workspaceId) ?? (workspaceId ? [workspaceId] : []), + isVisible, + onDiscoveryCompleted, + ); + const observeDiscovery = discovery.observe; + const catalog = useWorkspaceSessionCatalog({ + workspaceId, + enabled: isVisible && !workspaceScopes?.length && metadataPageState.loadedScope === initialMetadataKey, + refreshVersion: catalogRefreshVersion, + onPage: observeDiscovery, + }); const [openMenuSessionId, setOpenMenuSessionId] = useState(null); /** Second level of the session menu: pick what a Markdown export includes. */ const [isExportScopeMenu, setIsExportScopeMenu] = useState(false); @@ -352,14 +347,8 @@ const SessionsSection: React.FC = ({ ? contextSessionMenuPosition : anchoredSessionMenuPosition; const metadataLoadRequestIdRef = useRef(0); - /** User-driven metadata loads still running; background loads yield to them. */ - const foregroundLoadCountRef = useRef(0); const initialMetadataLoadKeyRef = useRef(null); const sessionListRef = useRef(null); - /** Last (scope, live, synced) triple a background reconcile ran for. */ - const liveReconcileSignatureRef = useRef(null); - /** Last (scope, cursor, size) triple a buffer prefetch ran for. */ - const bufferPrefetchSignatureRef = useRef(null); useEffect(() => { const selector = createSessionsNavSelector(); @@ -403,18 +392,13 @@ const SessionsSection: React.FC = ({ useEffect(() => { metadataLoadRequestIdRef.current += 1; initialMetadataLoadKeyRef.current = null; - liveReconcileSignatureRef.current = null; - bufferPrefetchSignatureRef.current = null; - setExpandLevel(0); setMetadataPageState({ - totalTopLevelCount: null, - syncedTopLevelCount: null, - nextCursor: undefined, - hasMore: false, + loadedScope: null, isLoading: false, loadError: false, }); - }, [workspaceId]); + return () => { metadataLoadRequestIdRef.current += 1; }; + }, [initialMetadataKey]); const workspaceScopesKey = useMemo( () => workspaceScopes?.map(scope => scope.workspaceId).join('|') ?? '', @@ -437,8 +421,10 @@ const SessionsSection: React.FC = ({ const page = await flowChatStore.loadSessionMetadataPage( scope.workspaceId, SESSIONS_LEVEL_2_PAGE, cursor, 'sessions_nav_all_grouping', + { fresh: aggregateReloadRequestId > 0 }, ); if (cancelled) return; + observeDiscovery(scope.workspaceId, page); cursor = page.hasMore ? page.nextCursor : undefined; } while (cursor); @@ -477,6 +463,7 @@ const SessionsSection: React.FC = ({ }, [ aggregateReloadRequestId, isVisible, + observeDiscovery, sessionFilters.hideArchived, workspaceScopes, workspaceScopesKey, @@ -501,58 +488,30 @@ const SessionsSection: React.FC = ({ limit: number, cursor: string | undefined, source: string, - options?: { background?: boolean }, ) => { if (!workspaceId || limit <= 0) { return null; } - // A background refresh only re-syncs counts for a list that is already on - // screen. Surfacing its spinner (or its retry state) would make routine - // upkeep — such as replacing the row a delete consumed — flash the list. - // It also leaves the request id alone so it cannot strand a user-driven - // load (which would otherwise never clear its spinner); instead it drops - // its own result if anything else claimed the list meanwhile. - const isBackgroundLoad = options?.background === true; - if (isBackgroundLoad && foregroundLoadCountRef.current > 0) { - return null; - } - - const requestId = isBackgroundLoad - ? metadataLoadRequestIdRef.current - : metadataLoadRequestIdRef.current + 1; - if (!isBackgroundLoad) { - metadataLoadRequestIdRef.current = requestId; - foregroundLoadCountRef.current += 1; - setMetadataPageState(prev => ({ - ...prev, - isLoading: true, - loadError: false, - })); - } + const requestId = ++metadataLoadRequestIdRef.current; + setMetadataPageState(prev => ({ ...prev, isLoading: true, loadError: false })); try { const page = await flowChatStore.loadSessionMetadataPage( workspaceId, limit, cursor, source ); - if (metadataLoadRequestIdRef.current === requestId) { - const syncedTopLevelCount = countTopLevelSessionsInScope( - flowChatStore.getState().sessions.values(), - workspaceId, - ); + if (metadataLoadRequestIdRef.current === requestId && surface.isCurrent()) { + observeDiscovery(workspaceId, page); setMetadataPageState({ - totalTopLevelCount: page.totalTopLevelCount, - syncedTopLevelCount, - nextCursor: page.nextCursor, - hasMore: page.hasMore, + loadedScope: initialMetadataKey, isLoading: false, loadError: false, }); } return page; } catch (error) { - if (metadataLoadRequestIdRef.current === requestId && !isBackgroundLoad) { + if (metadataLoadRequestIdRef.current === requestId && surface.isCurrent()) { setMetadataPageState(prev => ({ ...prev, isLoading: false, @@ -561,17 +520,11 @@ const SessionsSection: React.FC = ({ } log.warn('Failed to load visible session metadata page', { error, workspaceId, cursor, limit }); return null; - } finally { - if (!isBackgroundLoad) { - foregroundLoadCountRef.current = Math.max(foregroundLoadCountRef.current - 1, 0); - } } }, - [workspaceId] + [initialMetadataKey, observeDiscovery, surface, workspaceId] ); - const initialMetadataKey = workspaceId ?? ''; - const loadInitialMetadataPage = useCallback( async (source: string) => { if (!workspaceId) { @@ -655,40 +608,15 @@ const SessionsSection: React.FC = ({ workspaceId, ]); - useEffect(() => { - const needsExpandedDataset = sessionOrdering !== 'updated' || hasActiveSessionFilter; - if (!needsExpandedDataset || !isVisible || !workspaceId) { - return; - } - - setExpandLevel(2); - setLevel2DisplayCount(SESSIONS_LEVEL_2_PAGE); - void loadMetadataPage( - SESSIONS_LEVEL_2_PAGE, - undefined, - 'sessions_nav_view_preferences', - ); - }, [ - isVisible, - loadMetadataPage, - sessionOrdering, - hasActiveSessionFilter, - workspaceId, - ]); - // When sessions are archived, reset stale metadata so the expand toggle // doesn't linger with old counts after all sessions are gone. useEffect(() => { const handler = () => { metadataLoadRequestIdRef.current += 1; - liveReconcileSignatureRef.current = null; - bufferPrefetchSignatureRef.current = null; + setCatalogRefreshVersion(value => value + 1); setExpandLevel(0); setMetadataPageState({ - totalTopLevelCount: null, - syncedTopLevelCount: null, - nextCursor: undefined, - hasMore: false, + loadedScope: null, isLoading: false, loadError: false, }); @@ -698,7 +626,7 @@ const SessionsSection: React.FC = ({ }; window.addEventListener('openbitfun:session-archived', handler); return () => window.removeEventListener('openbitfun:session-archived', handler); - }, [isVisible, workspaceId, loadMetadataPage]); + }, [isVisible, workspaceId, loadMetadataPage, setExpandLevel]); const closeSessionMenu = useCallback(() => { setOpenMenuSessionId(null); @@ -757,45 +685,13 @@ const SessionsSection: React.FC = ({ }; }, [isExportScopeMenu, openMenuSessionId, updateContextSessionMenuPosition]); - const sessions = useMemo( - () => - Array.from(flowChatState.sessions.values()) - .filter((s: Session) => { - if (s.isTransient) { - return false; - } - if (s.sessionKind === 'subagent') { - return false; - } - if (workspaceScopes?.length) { - return workspaceScopes.some(scope => sessionBelongsToWorkspaceNavRow(s, scope.workspaceId)); - } - if (workspaceId) { - return sessionBelongsToWorkspaceNavRow(s, workspaceId); - } - return !s.workspaceId; - }), - [flowChatState.sessions, workspaceId, workspaceScopes] - ); - const { topLevelSessions: allTopLevelSessions, childrenByParent } = useMemo(() => { // Activity timestamps can change independently of the session records. void orderingRevision; - const childMap = new Map(); - const parents: Session[] = []; - - const knownIds = new Set(sessions.map(s => s.sessionId)); - - for (const s of sessions) { - const pid = resolveSessionRelationship(s).parentSessionId; - if (pid && typeof pid === 'string' && pid.trim() && knownIds.has(pid)) { - const list = childMap.get(pid) || []; - list.push(s); - childMap.set(pid, list); - } else { - parents.push(s); - } - } + const { topLevelSessions: parents, childrenByParent: childMap } = getWorkspaceSessionHierarchy( + flowChatState.sessions.values(), + workspaceScopes?.length ? workspaceScopes.map(scope => scope.workspaceId) : workspaceId ? [workspaceId] : [], + ); const compareForCurrentView = (left: Session, right: Session) => compareWorkspaceNavSessions( @@ -815,16 +711,16 @@ const SessionsSection: React.FC = ({ topLevelSessions: [...parents].sort(compareForCurrentView), childrenByParent: childMap, }; - }, [orderingRevision, runningSessionIds, sessionOrdering, sessions]); + }, [flowChatState.sessions, orderingRevision, runningSessionIds, sessionOrdering, workspaceId, workspaceScopes]); const topLevelSessions = useMemo( - () => allTopLevelSessions.filter(session => matchesWorkspaceSessionView( + () => allTopLevelSessions.filter(session => matchesSourceAgent(session) && matchesWorkspaceSessionView( session, sessionShow, sessionFilters, runningSessionIds.has(session.sessionId), )), - [allTopLevelSessions, runningSessionIds, sessionFilters, sessionShow], + [allTopLevelSessions, matchesSourceAgent, runningSessionIds, sessionFilters, sessionShow], ); const visibleChildrenByParent = useMemo(() => { @@ -832,7 +728,7 @@ const SessionsSection: React.FC = ({ const filtered = new Map(); for (const [parentId, children] of childrenByParent) { - filtered.set(parentId, children.filter(session => matchesWorkspaceSessionView( + filtered.set(parentId, children.filter(session => matchesSourceAgent(session) && matchesWorkspaceSessionView( session, sessionShow, sessionFilters, @@ -840,7 +736,7 @@ const SessionsSection: React.FC = ({ ))); } return filtered; - }, [childrenByParent, hasActiveSessionFilter, runningSessionIds, sessionFilters, sessionShow]); + }, [childrenByParent, hasActiveSessionFilter, matchesSourceAgent, runningSessionIds, sessionFilters, sessionShow]); const sessionDisplayLimit = useMemo(() => { return getSessionDisplayLimit({ @@ -851,32 +747,11 @@ const SessionsSection: React.FC = ({ }); }, [topLevelSessions.length, expandLevel, level2DisplayCount, showAllWithoutLimit]); - // A linked worktree stores its sessions in its main workspace's session root, - // so a metadata page loaded for that directory counts the project's sessions - // as well. That total cannot describe this row: the extra rows it counts belong - // to the project, and a "show more" affordance built on it promises rows this - // list can never reveal. Only the rows this workspace owns are counted here. - // Resolve the row's own workspace, not the active one, because a nested row - // renders while another workspace is active. - const sectionWorkspace = workspaceId - ? openedWorkspacesList.find(workspace => workspace.id === workspaceId) ?? null - : null; - const countOnlyOwnedTopLevelSessions = isLinkedWorktreeWorkspace(sectionWorkspace); - - const totalTopLevelSessionCount = - !hasActiveSessionFilter && !workspaceScopes?.length && !countOnlyOwnedTopLevelSessions - ? getEffectiveTopLevelSessionCount( - metadataPageState.totalTopLevelCount, - metadataPageState.syncedTopLevelCount, - allTopLevelSessions.length, - metadataPageState.isLoading, - ) - : topLevelSessions.length; - const hasMoreUnloadedSessions = - !hasActiveSessionFilter - && !workspaceScopes?.length - && !countOnlyOwnedTopLevelSessions - && allTopLevelSessions.length < totalTopLevelSessionCount; + // A storage root can include several workspace rows, including the main + // checkout. Its page total is never the total for this filtered list. + const totalTopLevelSessionCount = topLevelSessions.length; + const isSessionCountComplete = !workspaceId || Boolean(workspaceScopes?.length) + || (catalog.complete && !discovery.pending && !discovery.failed); const expandToggleState = getSessionExpandToggleState(totalTopLevelSessionCount, expandLevel); // The visible label stays short ("Show more") and the remaining count rides in // a trailing `+N` chip; screen readers get the full sentence via aria-label. @@ -891,16 +766,16 @@ const SessionsSection: React.FC = ({ const count = expandToggleState.collapsedRemainingCount; return { label: t('nav.sessions.showMoreLabel'), - ariaLabel: t('nav.sessions.showMore', { count }), - remainingCount: count, + ariaLabel: isSessionCountComplete ? t('nav.sessions.showMore', { count }) : t('nav.sessions.showMoreLabel'), + remainingCount: isSessionCountComplete ? count : null, }; } if (expandToggleState.action === 'show-all') { const count = expandToggleState.expandedRemainingCount; return { label: t('nav.sessions.showAllLabel'), - ariaLabel: t('nav.sessions.showAll', { count }), - remainingCount: count, + ariaLabel: isSessionCountComplete ? t('nav.sessions.showAll', { count }) : t('nav.sessions.showAllLabel'), + remainingCount: isSessionCountComplete ? count : null, }; } return { @@ -912,101 +787,10 @@ const SessionsSection: React.FC = ({ expandToggleState.action, expandToggleState.collapsedRemainingCount, expandToggleState.expandedRemainingCount, + isSessionCountComplete, t, ]); - useEffect(() => { - if ( - !isVisible || - !workspaceId || - metadataPageState.isLoading || - metadataPageState.totalTopLevelCount === null || - metadataPageState.syncedTopLevelCount === null || - allTopLevelSessions.length === metadataPageState.syncedTopLevelCount - ) { - return; - } - - // Re-running for a scope whose counts have not moved would spin on a - // failing backend, since a background load leaves the state untouched. - const signature = [ - initialMetadataKey, - allTopLevelSessions.length, - metadataPageState.syncedTopLevelCount, - ].join('\n'); - if (liveReconcileSignatureRef.current === signature) { - return; - } - liveReconcileSignatureRef.current = signature; - - void loadMetadataPage(SESSIONS_LEVEL_0, undefined, 'sessions_nav_live_reconcile', { - background: true, - }); - }, [ - initialMetadataKey, - isVisible, - loadMetadataPage, - metadataPageState.isLoading, - metadataPageState.syncedTopLevelCount, - metadataPageState.totalTopLevelCount, - allTopLevelSessions.length, - workspaceId, - ]); - - // Keep a few rows loaded past the visible slice. Deleting a session then - // promotes an already-loaded row in the same commit instead of leaving a gap - // until a metadata round trip lands. - useEffect(() => { - if ( - !isVisible || - !workspaceId || - metadataPageState.isLoading || - metadataPageState.loadError || - metadataPageState.totalTopLevelCount === null || - !metadataPageState.nextCursor - ) { - return; - } - - const prefetchLimit = getSessionBufferPrefetchLimit({ - expandLevel, - loadedTopLevelCount: allTopLevelSessions.length, - totalTopLevelCount: totalTopLevelSessionCount, - hasMore: metadataPageState.hasMore, - }); - if (prefetchLimit <= 0) { - return; - } - - const signature = [initialMetadataKey, metadataPageState.nextCursor, prefetchLimit].join('\n'); - if (bufferPrefetchSignatureRef.current === signature) { - return; - } - - const cursor = metadataPageState.nextCursor; - const timer = window.setTimeout(() => { - bufferPrefetchSignatureRef.current = signature; - void loadMetadataPage(prefetchLimit, cursor, 'sessions_nav_buffer_prefetch', { - background: true, - }); - }, SESSIONS_BUFFER_PREFETCH_DELAY_MS); - - return () => window.clearTimeout(timer); - }, [ - allTopLevelSessions.length, - expandLevel, - initialMetadataKey, - isVisible, - loadMetadataPage, - metadataPageState.hasMore, - metadataPageState.isLoading, - metadataPageState.loadError, - metadataPageState.nextCursor, - metadataPageState.totalTopLevelCount, - totalTopLevelSessionCount, - workspaceId, - ]); - const visibleItems = useMemo(() => { const visibleParents = topLevelSessions.slice(0, sessionDisplayLimit); const out: Array<{ session: Session; level: 0 | 1 }> = []; @@ -1269,12 +1053,15 @@ const SessionsSection: React.FC = ({ async (e: React.MouseEvent, sessionId: string) => { e.stopPropagation(); const session = flowChatStore.getState().sessions.get(sessionId); + const sourceView = session?.externalHistory?.mode === 'follow'; const confirmed = await confirmDanger( - t('nav.sessions.deleteConfirmTitle'), - t('nav.sessions.deleteConfirmMessage', { + sourceView ? t('nav.sessions.removeSourceConfirmTitle') : t('nav.sessions.deleteConfirmTitle'), + sourceView ? t('nav.sessions.removeSourceConfirmMessage', { + name: session ? resolveSessionTitle(session) : t('nav.sessions.untitled'), + }) : t('nav.sessions.deleteConfirmMessage', { name: session ? resolveSessionTitle(session) : t('nav.sessions.untitled'), }), - { confirmText: t('nav.sessions.delete') }, + { confirmText: sourceView ? t('nav.sessions.removeSource') : t('nav.sessions.delete') }, ); if (!confirmed) return; @@ -1437,58 +1224,33 @@ const SessionsSection: React.FC = ({ [handleConfirmEdit, handleCancelEdit] ); - const handleExpandToggle = useCallback(async () => { - if (metadataPageState.isLoading) { - return; - } - - const loadedTopLevelCount = topLevelSessions.length; - const total = totalTopLevelSessionCount; - + const handleExpandToggle = useCallback(() => { if (expandLevel === 0) { - const targetCount = Math.min(total, SESSIONS_LEVEL_1); - if ( - loadedTopLevelCount < targetCount && - hasMoreUnloadedSessions && - metadataPageState.nextCursor - ) { - await loadMetadataPage( - targetCount - loadedTopLevelCount, - metadataPageState.nextCursor, - 'sessions_nav_expand_level_1' - ); - } setExpandLevel(1); return; } - - if (expandLevel === 1 && total > SESSIONS_LEVEL_1) { - if ( - loadedTopLevelCount < total && - hasMoreUnloadedSessions && - metadataPageState.nextCursor - ) { - await loadMetadataPage( - total - loadedTopLevelCount, - metadataPageState.nextCursor, - 'sessions_nav_expand_all' - ); - } + if (expandLevel === 1 && totalTopLevelSessionCount > SESSIONS_LEVEL_1) { setExpandLevel(2); return; } - setExpandLevel(0); - }, [ - expandLevel, - hasMoreUnloadedSessions, - loadMetadataPage, - metadataPageState.isLoading, - metadataPageState.nextCursor, - topLevelSessions.length, - totalTopLevelSessionCount, - ]); - + }, [expandLevel, setExpandLevel, totalTopLevelSessionCount]); + + const sourceFilterStatus = discovery.failed ? ( + + ) : catalog.loading || discovery.pending ? ( +
+
+ ) : catalog.failed ? ( + + ) : null; + const discoveryNotice = discovery.warnings.length > 0 ? ( + + ) : null; const aggregateLoadStatus = showAllWithoutLimit ? aggregateLoadState.isLoading ? ( @@ -1522,6 +1284,7 @@ const SessionsSection: React.FC = ({ : null; if (allTopLevelSessions.length === 0) { + if (sourceFilterStatus) return
{sourceFilterStatus}{discoveryNotice}
; if (aggregateLoadStatus) { return (
@@ -1557,6 +1320,7 @@ const SessionsSection: React.FC = ({ } return (
+ {discoveryNotice} {presentation?.kind === 'assistant' ? (
= ({ } if (topLevelSessions.length === 0) { + if (sourceFilterStatus) return
{sourceFilterStatus}{discoveryNotice}
; if (aggregateLoadState.isLoading && aggregateLoadStatus) { return (
@@ -1590,6 +1355,7 @@ const SessionsSection: React.FC = ({ } return (
+ {discoveryNotice}
{t('nav.sessions.viewMenu.noMatches')}
@@ -1600,6 +1366,8 @@ const SessionsSection: React.FC = ({ return (
+ {sourceFilterStatus} + {discoveryNotice} {visibleItems.map(({ session, level }) => { const isEditing = editingSessionId === session.sessionId; const relationship = resolveSessionRelationship(session); @@ -1851,6 +1619,12 @@ const SessionsSection: React.FC = ({ {t('nav.sessions.defaultSession')} )} + {session.importedSource ? ( + + {sessionSourceAgentLabel(session.importedSource)} + + ) : null} {isChildSession ? ( {childSessionBadge} @@ -2148,7 +1922,7 @@ const SessionsSection: React.FC = ({ data-testid="nav-session-menu-delete" data-session-id={session.sessionId} > - {t('nav.sessions.delete')} + {t(session.externalHistory?.mode === 'follow' ? 'nav.sessions.removeSource' : 'nav.sessions.delete')} )} @@ -2181,17 +1955,19 @@ const SessionsSection: React.FC = ({ type="button" className="openbitfun-nav-panel__inline-toggle" data-testid="nav-session-list-load-more" - aria-label={t('nav.sessions.showMore', { + aria-label={isSessionCountComplete ? t('nav.sessions.showMore', { count: topLevelSessions.length - sessionDisplayLimit, - })} + }) : t('nav.sessions.showMoreLabel')} onClick={() => setLevel2DisplayCount(prev => prev + SESSIONS_LEVEL_2_PAGE)} > {t('nav.sessions.showMoreLabel')} - - +{topLevelSessions.length - sessionDisplayLimit} - + {isSessionCountComplete && ( + + +{topLevelSessions.length - sessionDisplayLimit} + + )} @@ -2222,7 +1998,7 @@ const SessionsSection: React.FC = ({ {metadataPageState.isLoading ? ( - ) : expandToggleLabels.remainingCount === null ? ( + ) : expandToggleState.action === 'show-less' ? ( ) : ( diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts index 2aa12e3443..8ca6c44d7c 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts @@ -24,7 +24,7 @@ function extractInlineItemActionsBlock(stylesheet: string): string { } function extractInlineItemBlock(stylesheet: string, element: string): string { - const match = stylesheet.match(new RegExp(`&__inline-item-${element}\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}`)); + const match = stylesheet.match(new RegExp(`&__inline-item-${element}(?:\\s*,\\s*&__[\\w-]+)*\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}`)); return match?.groups?.body ?? ''; } @@ -159,7 +159,7 @@ describe('SessionsSection layout styles', () => { expect(source).toMatch(/]*className="openbitfun-nav-panel__inline-toggle-label"/); // The chip is decorative; the full sentence stays on the button's aria-label. expect(source).toContain('className="openbitfun-nav-panel__inline-toggle-count" aria-hidden'); - expect(source).toContain("aria-label={t('nav.sessions.showMore', {"); + expect(source).toContain("aria-label={isSessionCountComplete ? t('nav.sessions.showMore', {"); expect(source).toContain('aria-label={expandToggleLabels.ariaLabel}'); expect(source).not.toContain('inline-toggle-dots'); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionActionConfirmation.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionActionConfirmation.test.ts index 7ea97a8e9c..9146a3a07f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionActionConfirmation.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionActionConfirmation.test.ts @@ -27,7 +27,7 @@ describe('session action confirmation policy', () => { expect(deleteHandler).toContain('await confirmDanger('); expect(deleteHandler).toContain("t('nav.sessions.deleteConfirmTitle')"); expect(deleteHandler).toContain("t('nav.sessions.deleteConfirmMessage', {"); - expect(deleteHandler).toContain("{ confirmText: t('nav.sessions.delete') }"); + expect(deleteHandler).toContain("{ confirmText: sourceView ? t('nav.sessions.removeSource') : t('nav.sessions.delete') }"); expect(deleteHandler.indexOf('await confirmDanger(')).toBeLessThan( deleteHandler.indexOf('await flowChatManager.deleteChatSession(sessionId)'), ); @@ -46,7 +46,7 @@ describe('session action confirmation policy', () => { expect(archiveHandler).toContain('flowChatManager.archiveChatSession(sessionId)'); expect(deleteHandler).toContain('await confirmDanger('); expect(deleteHandler).toContain("t('nav.sessions.bulkDeleteConfirmTitle')"); - expect(deleteHandler).toContain("{ confirmText: t('nav.sessions.deleteSelected') }"); + expect(deleteHandler).toContain("{ confirmText: onlySources ? t('nav.sessions.removeSelected') : t('nav.sessions.deleteSelected') }"); expect(deleteHandler.indexOf('await confirmDanger(')).toBeLessThan( deleteHandler.indexOf('await flowChatManager.deleteChatSession(rootId)'), ); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts index 35a28b3ba0..598d595133 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - getEffectiveTopLevelSessionCount, - getSessionBufferPrefetchLimit, getSessionDisplayLimit, getSessionExpandToggleState, } from './sessionNavExpand'; @@ -76,84 +74,3 @@ describe('getSessionExpandToggleState', () => { }); }); }); - -describe('getEffectiveTopLevelSessionCount', () => { - it('falls back to the live count before metadata is available', () => { - expect(getEffectiveTopLevelSessionCount(null, null, 3, false)).toBe(3); - }); - - it('keeps live creates visible even when metadata count is stale', () => { - expect(getEffectiveTopLevelSessionCount(1, 1, 6, false)).toBe(6); - }); - - it('reduces the total count when loaded sessions are deleted', () => { - expect(getEffectiveTopLevelSessionCount(6, 5, 4, false)).toBe(5); - expect(getEffectiveTopLevelSessionCount(6, 5, 3, false)).toBe(4); - }); - - it('avoids overcorrecting while a metadata refresh is still loading', () => { - expect(getEffectiveTopLevelSessionCount(12, 5, 10, true)).toBe(12); - }); -}); - -describe('getSessionBufferPrefetchLimit', () => { - it('tops the collapsed view up to a few rows past what it renders', () => { - expect(getSessionBufferPrefetchLimit({ - expandLevel: 0, - loadedTopLevelCount: 5, - totalTopLevelCount: 40, - hasMore: true, - })).toBe(3); - }); - - it('refills only the rows a delete consumed', () => { - expect(getSessionBufferPrefetchLimit({ - expandLevel: 0, - loadedTopLevelCount: 7, - totalTopLevelCount: 40, - hasMore: true, - })).toBe(1); - }); - - it('stops once the buffer is full', () => { - expect(getSessionBufferPrefetchLimit({ - expandLevel: 0, - loadedTopLevelCount: 8, - totalTopLevelCount: 40, - hasMore: true, - })).toBe(0); - }); - - it('never asks for more sessions than the workspace has', () => { - expect(getSessionBufferPrefetchLimit({ - expandLevel: 0, - loadedTopLevelCount: 5, - totalTopLevelCount: 6, - hasMore: true, - })).toBe(1); - }); - - it('buffers past the first expand step as well', () => { - expect(getSessionBufferPrefetchLimit({ - expandLevel: 1, - loadedTopLevelCount: 10, - totalTopLevelCount: 40, - hasMore: true, - })).toBe(3); - }); - - it('skips prefetching when everything is loaded or fully expanded', () => { - expect(getSessionBufferPrefetchLimit({ - expandLevel: 0, - loadedTopLevelCount: 5, - totalTopLevelCount: 5, - hasMore: false, - })).toBe(0); - expect(getSessionBufferPrefetchLimit({ - expandLevel: 2, - loadedTopLevelCount: 40, - totalTopLevelCount: 400, - hasMore: true, - })).toBe(0); - }); -}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts index ae402521a8..ddb63f1710 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavExpand.ts @@ -1,14 +1,6 @@ export const SESSIONS_LEVEL_0 = 5; export const SESSIONS_LEVEL_1 = 10; -/** - * Rows kept loaded past the visible slice. Deleting a session drops one row out - * of the list, and without a buffer the row that takes its place only arrives - * after a metadata round trip — the list would visibly close up and then push a - * row back in. The buffer lets the replacement render in the same commit. - */ -export const SESSIONS_BUFFER_PREFETCH = 3; - export type SessionExpandLevel = 0 | 1 | 2; export type SessionExpandToggleAction = 'show-more' | 'show-all' | 'show-less'; @@ -47,47 +39,6 @@ export function getSessionDisplayLimit(params: { return SESSIONS_LEVEL_0; } -export function getEffectiveTopLevelSessionCount( - metadataTotalTopLevelCount: number | null, - syncedTopLevelCount: number | null, - liveTopLevelCount: number, - isMetadataLoading: boolean -): number { - if (metadataTotalTopLevelCount === null) { - return liveTopLevelCount; - } - - if (isMetadataLoading || syncedTopLevelCount === null) { - return Math.max(metadataTotalTopLevelCount, liveTopLevelCount); - } - - return Math.max( - liveTopLevelCount, - metadataTotalTopLevelCount + (liveTopLevelCount - syncedTopLevelCount) - ); -} - -/** - * How many extra top-level rows the collapsed/expanded view should keep loaded - * beyond what it renders. Returns 0 when the buffer is already full, when every - * session is loaded, or when the list is fully expanded (level 2 pages itself). - */ -export function getSessionBufferPrefetchLimit(params: { - expandLevel: SessionExpandLevel; - loadedTopLevelCount: number; - totalTopLevelCount: number; - hasMore: boolean; -}): number { - const { expandLevel, loadedTopLevelCount, totalTopLevelCount, hasMore } = params; - if (!hasMore || expandLevel === 2) { - return 0; - } - - const visibleCount = expandLevel === 0 ? SESSIONS_LEVEL_0 : SESSIONS_LEVEL_1; - const targetLoadedCount = Math.min(visibleCount + SESSIONS_BUFFER_PREFETCH, totalTopLevelCount); - return Math.max(targetLoadedCount - loadedTopLevelCount, 0); -} - export function getSessionExpandToggleState( totalTopLevelSessionCount: number, expandLevel: SessionExpandLevel diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavHierarchy.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavHierarchy.test.ts new file mode 100644 index 0000000000..64fd9cf3b9 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavHierarchy.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import { + DEFAULT_WORKSPACE_SESSION_FILTERS, + matchesWorkspaceSessionView, + matchesWorkspaceSourceAgent, + type WorkspaceSessionSourceAgent, +} from '../../workspaceSessionView'; +import { getWorkspaceSessionHierarchy } from './sessionNavHierarchy'; +import { getSessionDisplayLimit, getSessionExpandToggleState } from './sessionNavExpand'; + +const session = (sessionId: string, workspaceId: string, extra: Partial = {}): Session => ({ + sessionId, workspaceId, projectWorkspaceId: 'main', title: sessionId, + config: {}, createdAt: 1, lastActiveAt: 1, status: 'idle', persistedStatus: 'active', + sessionKind: 'normal', error: null, dialogTurns: [], ...extra, +} as Session); + +const visibleParents = (sessions: Session[], workspaceId: string, source: WorkspaceSessionSourceAgent = 'all') => + getWorkspaceSessionHierarchy(sessions, [workspaceId]).topLevelSessions.filter(row => + matchesWorkspaceSourceAgent(row, source) + && matchesWorkspaceSessionView(row, 'all', DEFAULT_WORKSPACE_SESSION_FILTERS, false)); + +describe('workspace-owned navigation counts', () => { + it('counts a shared 659-record catalog as 24 native + 432 Codex rows for the main workspace', () => { + const records = [ + ...Array.from({ length: 23 }, (_, index) => session(`native-${index}`, 'main')), + session('managed', 'managed-worktree', { + config: { executionTarget: { kind: 'managedWorktree', rootPath: '/repo/.worktrees/task' } }, + }), + ...Array.from({ length: 432 }, (_, index) => session(`codex-${index}`, 'main', { importedSource: 'codex' })), + ...Array.from({ length: 94 }, (_, index) => session(`ui-${index}`, 'ui', { importedSource: 'codex' })), + ...Array.from({ length: 105 }, (_, index) => session(`cli-${index}`, 'cli', { importedSource: 'codex' })), + ...Array.from({ length: 3 }, (_, index) => session(`child-${index}`, 'main', { + sessionKind: 'btw', parentSessionId: 'native-0', + })), + session('ui-child', 'ui', { sessionKind: 'review', parentSessionId: 'ui-0' }), + ]; + expect(records).toHaveLength(659); + expect(getWorkspaceSessionHierarchy(records, ['main', 'ui', 'cli']).topLevelSessions).toHaveLength(655); + for (const [source, total, remaining] of [ + ['openbitfun', 24, 19], ['codex', 432, 427], ['all', 456, 451], + ] as const) { + const rows = visibleParents(records, 'main', source); + expect(rows).toHaveLength(total); + expect(getSessionExpandToggleState(rows.length, 0).collapsedRemainingCount).toBe(remaining); + expect(getSessionDisplayLimit({ loadedTopLevelCount: rows.length, expandLevel: 0, level2DisplayCount: 200, showAllWithoutLimit: false })).toBe(5); + } + expect(visibleParents(records, 'ui')).toHaveLength(94); + expect(visibleParents(records, 'cli')).toHaveLength(105); + expect(visibleParents(records, 'managed-worktree')).toHaveLength(0); + }); + + it('tracks creates, deletes and archives from the same filtered rows', () => { + let records = Array.from({ length: 6 }, (_, index) => session(`row-${index}`, 'main')); + const remaining = () => getSessionExpandToggleState(visibleParents(records, 'main').length, 0).collapsedRemainingCount; + expect(remaining()).toBe(1); + records.push(session('created', 'main')); + expect(remaining()).toBe(2); + records = records.filter(row => row.sessionId !== 'row-0'); + expect(remaining()).toBe(1); + records[0] = { ...records[0], persistedStatus: 'archived' }; + expect(remaining()).toBe(0); + expect(getSessionExpandToggleState(visibleParents(records, 'main').length, 0).shouldRender).toBe(false); + }); + + it('keeps unresolved children visible and excludes transient and subagent records', () => { + const records = [ + session('parent', 'main'), + session('child', 'main', { sessionKind: 'btw', parentSessionId: 'parent' }), + session('orphan', 'main', { sessionKind: 'btw', parentSessionId: 'unknown' }), + session('transient', 'main', { isTransient: true }), + session('agent', 'main', { sessionKind: 'subagent', parentSessionId: 'parent' }), + session('foreign', 'linked-worktree'), + ]; + const hierarchy = getWorkspaceSessionHierarchy(records, ['main']); + expect(hierarchy.topLevelSessions.map(row => row.sessionId)).toEqual(['parent', 'orphan']); + expect(hierarchy.childrenByParent.get('parent')?.map(row => row.sessionId)).toEqual(['child']); + }); + + it('honors legacy config ownership and POSIX remote IDs without inferring ownership from paths', () => { + const records = [ + session('legacy', '', { workspaceId: undefined, projectWorkspaceId: undefined, config: { workspaceId: 'ssh-main' } }), + session('remote-tree', 'ssh-tree', { projectWorkspaceId: 'ssh-main' }), + session('remote-managed', 'ssh-managed', { projectWorkspaceId: 'ssh-main', config: { + executionTarget: { kind: 'managedWorktree', rootPath: '/srv/repo/.worktrees/task' }, + } }), + ]; + expect(visibleParents(records, 'ssh-main').map(row => row.sessionId)).toEqual(['legacy', 'remote-managed']); + expect(visibleParents(records, 'ssh-tree').map(row => row.sessionId)).toEqual(['remote-tree']); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavHierarchy.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavHierarchy.ts new file mode 100644 index 0000000000..dd5604c043 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionNavHierarchy.ts @@ -0,0 +1,30 @@ +import type { Session } from '@/flow_chat/types/flow-chat'; +import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; +import { sessionBelongsToWorkspaceNavRow } from '@/flow_chat/utils/sessionOrdering'; + +/** Build the same workspace-owned hierarchy for both rows and their count. */ +export function getWorkspaceSessionHierarchy( + sessions: Iterable, + workspaceIds: readonly string[], +): { topLevelSessions: Session[]; childrenByParent: Map } { + const scoped = Array.from(sessions).filter(session => { + if (session.isTransient || session.sessionKind === 'subagent') return false; + return workspaceIds.length + ? workspaceIds.some(workspaceId => sessionBelongsToWorkspaceNavRow(session, workspaceId)) + : !session.workspaceId; + }); + const knownIds = new Set(scoped.map(session => session.sessionId)); + const topLevelSessions: Session[] = []; + const childrenByParent = new Map(); + for (const session of scoped) { + const parentId = resolveSessionRelationship(session).parentSessionId; + if (parentId && knownIds.has(parentId)) { + const children = childrenByParent.get(parentId) ?? []; + children.push(session); + childrenByParent.set(parentId, children); + } else { + topLevelSessions.push(session); + } + } + return { topLevelSessions, childrenByParent }; +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts index 5f9765e50a..17cdfca8f7 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionWorktreeRow.test.ts @@ -54,21 +54,13 @@ describe('worktree isolated session row', () => { }); it('counts only the rows a linked worktree owns when sizing the expand toggle', () => { - // A linked worktree shares its main workspace's session directory, so the - // metadata page total counts the project's sessions too and must not drive - // the expand toggle. - expect(sessionsSection).toMatch( - /const sectionWorkspace = workspaceId\s*\n\s*\? openedWorkspacesList\.find\(workspace => workspace\.id === workspaceId\) \?\? null\s*\n\s*: null;/, - ); + // Both the main checkout and linked worktrees share the storage total. + // Ownership behavior is covered with real hierarchies in sessionNavHierarchy.test.ts. + expect(sessionsSection).toContain('getWorkspaceSessionHierarchy('); expect(sessionsSection).toContain( - 'const countOnlyOwnedTopLevelSessions = isLinkedWorktreeWorkspace(sectionWorkspace);', - ); - expect(sessionsSection).toMatch( - /!hasActiveSessionFilter && !workspaceScopes\?\.length && !countOnlyOwnedTopLevelSessions\s*\n\s*\? getEffectiveTopLevelSessionCount\(/, - ); - expect(sessionsSection).toMatch( - /import \{ isLinkedWorktreeWorkspace \} from '@\/shared\/types\/global-state';/, + 'const totalTopLevelSessionCount = topLevelSessions.length;', ); + expect(sessionsSection).not.toContain('getEffectiveTopLevelSessionCount'); }); it('explains the worktree execution in the row tooltip and badge title', () => { diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts index 4b3b913dbf..0505797a2c 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts @@ -18,6 +18,12 @@ function state(sessions: Session[], activeSessionId: string | null = null): Flow } describe('createSessionsNavSelector', () => { + it('notifies navigation when metadata supplies an imported source label', () => { + const select = createSessionsNavSelector(); + const value = session(); + expect(select(state([value]))).toBe(1); + expect(select(state([{ ...value, importedSource: 'codex' }]))).toBe(2); + }); it('reuses the revision when only non-navigation session data changes', () => { const select = createSessionsNavSelector(); const first = session(); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts index 8e715d5f2f..e0c0e27cf3 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts @@ -4,6 +4,7 @@ type DispatchTargetKind = 'local' | 'ssh' | 'device'; interface SessionNavSignature { sessionId: string; + importedSource: string | undefined; isTransient: boolean | undefined; sessionKind: Session['sessionKind']; parentSessionId: string | undefined; @@ -29,6 +30,7 @@ function readSessionNavSignature(session: Session): SessionNavSignature { return { sessionId: session.sessionId, + importedSource: session.importedSource, isTransient: session.isTransient, sessionKind: session.sessionKind, parentSessionId: session.parentSessionId, @@ -55,6 +57,7 @@ function sameSessionNavSignature(previous: SessionNavSignature, session: Session const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; return previous.sessionId === session.sessionId + && previous.importedSource === session.importedSource && previous.isTransient === session.isTransient && previous.sessionKind === session.sessionKind && previous.parentSessionId === session.parentSessionId diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.test.tsx new file mode 100644 index 0000000000..547c9c3b81 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getSessionDisplayLimit } from './sessionNavExpand'; +import { useSessionNavExpansion } from './useSessionNavExpansion'; + +describe('source-filter session expansion', () => { + let container: HTMLDivElement; + let root: Root; + const renders: Array<{ scope: string; rows: number }> = []; + + function List({ scope, count }: { scope: string; count: number }) { + const { expandLevel, level2DisplayCount, setExpandLevel, setLevel2DisplayCount } = useSessionNavExpansion(scope, 200); + const rows = getSessionDisplayLimit({ loadedTopLevelCount: count, expandLevel, level2DisplayCount, showAllWithoutLimit: false }); + renders.push({ scope, rows }); + return <> + {rows} + + + + + ; + } + + const render = (scope: string, count: number) => act(() => { root.render(); }); + const click = (action: string) => act(() => { container.querySelector(`[data-action="${action}"]`)!.click(); }); + const count = () => Number(container.querySelector('output')?.textContent); + + beforeEach(() => { + renders.length = 0; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('starts each agent filter with five rows, including the first render after an expanded view', () => { + render('local:project:all', 350); + expect(count()).toBe(5); + click('all'); + expect(count()).toBe(200); + render('local:project:codex', 300); + expect(renders.filter(render => render.scope === 'local:project:codex').every(render => render.rows === 5)).toBe(true); + expect(count()).toBe(5); + click('more'); + expect(count()).toBe(10); + render('local:project:zcode', 50); + expect(count()).toBe(5); + render('local:project:codex', 300); + expect(count()).toBe(5); + render('local:project:all', 350); + expect(count()).toBe(5); + }); + + it('keeps newly loaded metadata folded and expands only through the list controls', () => { + render('local:project:codex', 3); + expect(count()).toBe(3); + render('local:project:codex', 600); + expect(count()).toBe(5); + click('more'); + expect(count()).toBe(10); + click('all'); + expect(count()).toBe(200); + click('next'); + expect(count()).toBe(400); + click('less'); + expect(count()).toBe(5); + click('all'); + expect(count()).toBe(200); + render('peer:project:codex', 600); + expect(count()).toBe(5); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts new file mode 100644 index 0000000000..47c1002be9 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useState, type SetStateAction } from 'react'; +import type { SessionExpandLevel } from './sessionNavExpand'; + +/** Data discovery never expands the list. Each workspace/source view starts + * compact, even when another view already has hundreds of metadata rows. */ +export function useSessionNavExpansion(scope: string, pageSize: number) { + const [state, setState] = useState({ scope, level: 0 as SessionExpandLevel, displayCount: pageSize }); + useEffect(() => { + setState(previous => previous.scope === scope ? previous : { scope, level: 0, displayCount: pageSize }); + }, [pageSize, scope]); + + const setExpandLevel = useCallback((level: SessionExpandLevel) => { + setState(previous => ({ + scope, + level, + displayCount: level === 2 && previous.scope === scope ? previous.displayCount : pageSize, + })); + }, [pageSize, scope]); + + const setLevel2DisplayCount = useCallback((next: SetStateAction) => { + setState(previous => { + const current = previous.scope === scope ? previous.displayCount : pageSize; + return { + scope, + level: previous.scope === scope ? previous.level : 0, + displayCount: typeof next === 'function' ? next(current) : next, + }; + }); + }, [pageSize, scope]); + + return { + // Limit the very first render after a filter/device switch, before effects + // run, so the previous view cannot briefly mount its entire expanded list. + expandLevel: state.scope === scope ? state.level : 0 as SessionExpandLevel, + level2DisplayCount: state.scope === scope ? state.displayCount : pageSize, + setExpandLevel, + setLevel2DisplayCount, + }; +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionCatalog.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionCatalog.test.tsx new file mode 100644 index 0000000000..f24f09b4e2 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionCatalog.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; +import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/peer-device/deviceSurface'; + +const mocks = vi.hoisted(() => ({ loadPage: vi.fn(), onPage: vi.fn() })); +vi.mock('@/flow_chat/store/FlowChatStore', () => ({ flowChatStore: { loadSessionMetadataPage: mocks.loadPage } })); +vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ warn: vi.fn() }) })); +import { useWorkspaceSessionCatalog } from './useWorkspaceSessionCatalog'; + +const page = (nextCursor?: string): SessionMetadataPage => ({ + sessions: [], totalTopLevelCount: 655, loadedTopLevelCount: 200, hasMore: Boolean(nextCursor), nextCursor, +}); +const deferred = () => { + let resolve!: (value: SessionMetadataPage) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; +type Props = { workspaceId?: string; enabled?: boolean; refreshVersion?: number; agent?: string }; +let current: ReturnType; +const renders: Array<{ workspaceId: string; complete: boolean }> = []; +function Harness({ workspaceId = 'main', enabled = true, refreshVersion = 0, agent = 'all' }: Props) { + current = useWorkspaceSessionCatalog({ workspaceId, enabled, refreshVersion, onPage: mocks.onPage }); + renders.push({ workspaceId, complete: current.complete }); + return {current.complete ? 'complete' : current.failed ? 'failed' : 'pending'}; +} + +describe('workspace metadata catalog', () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + resetDeviceSurfaceForTest(); + renders.length = 0; + container = document.createElement('div'); + root = createRoot(container); + mocks.loadPage.mockResolvedValue(page()); + }); + afterEach(async () => { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + }); + const render = async (props: Props = {}) => { await act(async () => root.render()); }; + + it('waits for startup readiness, traverses legacy pages, and does not rescan when an Agent filter changes', async () => { + await render({ enabled: false }); + expect(mocks.loadPage).not.toHaveBeenCalled(); + const last = deferred(); + mocks.loadPage.mockResolvedValueOnce(page('200')).mockImplementationOnce(() => last.promise); + await render(); + expect(current.loading).toBe(true); + expect(current.complete).toBe(false); + expect(mocks.loadPage).toHaveBeenNthCalledWith(2, 'main', 200, '200', 'sessions_nav_catalog', { fresh: false }); + await act(async () => last.resolve(page())); + expect(current.complete).toBe(true); + await render({ agent: 'openbitfun' }); + await render({ agent: 'codex' }); + await render({ agent: 'all' }); + await render({ enabled: false }); + await render(); + expect(mocks.loadPage).toHaveBeenCalledTimes(2); + expect(mocks.onPage).toHaveBeenCalledTimes(2); + }); + + it('keeps partial results incomplete on failure and retries the entire catalog with fresh pages', async () => { + mocks.loadPage.mockResolvedValueOnce(page('200')).mockRejectedValueOnce(new Error('Disconnected')); + await render(); + expect(current).toMatchObject({ complete: false, failed: true, loading: false }); + expect(mocks.onPage).toHaveBeenCalledTimes(1); + mocks.loadPage.mockResolvedValueOnce(page()); + await render({ refreshVersion: 1 }); + expect(mocks.loadPage).toHaveBeenLastCalledWith('main', 200, undefined, 'sessions_nav_catalog', { fresh: true }); + expect(current).toMatchObject({ complete: true, failed: false }); + }); + + it('never carries a completed count into the first render of a different workspace', async () => { + await render(); + const other = deferred(); + mocks.loadPage.mockImplementationOnce(() => other.promise); + await render({ workspaceId: 'other' }); + expect(renders.filter(value => value.workspaceId === 'other').every(value => !value.complete)).toBe(true); + await act(async () => other.resolve(page())); + expect(current.complete).toBe(true); + }); + + it('stops a stale local traversal when the same workspace ID is shown on a peer', async () => { + const local = deferred(); + const peer = deferred(); + mocks.loadPage.mockImplementationOnce(() => local.promise).mockImplementationOnce(() => peer.promise); + await render(); + activateSurface('peer-device'); + await render(); + await act(async () => local.resolve(page('stale-cursor'))); + expect(mocks.onPage).not.toHaveBeenCalled(); + expect(mocks.loadPage).toHaveBeenCalledTimes(2); + expect(current.complete).toBe(false); + await act(async () => peer.resolve(page())); + expect(current.complete).toBe(true); + expect(mocks.onPage).toHaveBeenCalledTimes(1); + }); + + it('invalidates completion during discovery refresh even before another request finishes', async () => { + await render(); + const refresh = deferred(); + mocks.loadPage.mockImplementationOnce(() => refresh.promise); + await render({ refreshVersion: 1 }); + expect(current.complete).toBe(false); + await act(async () => refresh.resolve(page())); + expect(current.complete).toBe(true); + }); + + it('does not continue pagination after the section is hidden', async () => { + const pending = deferred(); + mocks.loadPage.mockImplementationOnce(() => pending.promise); + await render(); + await render({ enabled: false }); + await act(async () => pending.resolve(page('200'))); + expect(mocks.onPage).not.toHaveBeenCalled(); + expect(mocks.loadPage).toHaveBeenCalledTimes(1); + expect(current.complete).toBe(false); + await render(); + expect(current.complete).toBe(true); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionCatalog.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionCatalog.ts new file mode 100644 index 0000000000..3a3c12839d --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionCatalog.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef, useState } from 'react'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { createLogger } from '@/shared/utils/logger'; +import { loadWorkspaceSessionSourcePages } from './workspaceSessionSourcePages'; + +const log = createLogger('useWorkspaceSessionCatalog'); + +/** Metadata totals cover a shared storage root, not necessarily this workspace. + * Load its lightweight catalog once so every filter counts the actual rows. + * The caller enables this only after the startup page has finished. */ +export function useWorkspaceSessionCatalog({ + workspaceId, + enabled, + refreshVersion, + onPage, +}: { + workspaceId?: string; + enabled: boolean; + refreshVersion: number; + onPage: (workspaceId: string, page: SessionMetadataPage) => void; +}) { + const surface = getActiveSurfaceScope(); + const scopeKey = surface.key(workspaceId, surface.epoch, refreshVersion); + const completedKey = useRef(null); + const [state, setState] = useState({ scopeKey: '', loading: false, failed: false, complete: false }); + + useEffect(() => { + if (!enabled || !workspaceId || completedKey.current === scopeKey) return; + let cancelled = false; + const isCurrent = () => !cancelled && surface.isCurrent(); + setState({ scopeKey, loading: true, failed: false, complete: false }); + void loadWorkspaceSessionSourcePages(async cursor => { + const page = await flowChatStore.loadSessionMetadataPage( + workspaceId, 200, cursor, 'sessions_nav_catalog', + { fresh: refreshVersion > 0 }, + ); + if (isCurrent()) onPage(workspaceId, page); + return page; + }, isCurrent).then(() => { + if (!isCurrent()) return; + completedKey.current = scopeKey; + setState({ scopeKey, loading: false, failed: false, complete: true }); + }).catch(error => { + if (!isCurrent()) return; + log.warn('Failed to load workspace session catalog', { workspaceId, error }); + setState({ scopeKey, loading: false, failed: true, complete: false }); + }); + return () => { cancelled = true; }; + }, [enabled, onPage, refreshVersion, scopeKey, surface, workspaceId]); + + return state.scopeKey === scopeKey + ? state + : { scopeKey, loading: enabled && Boolean(workspaceId), failed: false, complete: false }; +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.test.tsx new file mode 100644 index 0000000000..dd5c2b5e4a --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.test.tsx @@ -0,0 +1,175 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; +const mocks = vi.hoisted(() => ({ loadPage: vi.fn(), completed: vi.fn(), epoch: 0, host: 'local', configListeners: new Set<(path: string) => void>() })); +vi.mock('@/flow_chat/store/FlowChatStore', () => ({ flowChatStore: { loadSessionMetadataPage: mocks.loadPage } })); +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ configManager: { + onConfigChange: (callback: (path: string) => void) => { + mocks.configListeners.add(callback); + return () => mocks.configListeners.delete(callback); + }, +} })); +vi.mock('@/infrastructure/peer-device/deviceSurface', () => ({ + getActiveSurfaceId: () => mocks.host, + surfaceScopedKey: (...parts: string[]) => JSON.stringify(parts), + getActiveSurfaceScope: () => { const epoch = mocks.epoch; return { isCurrent: () => epoch === mocks.epoch }; }, +})); +vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ warn: vi.fn() }) })); +import { useWorkspaceSessionDiscovery } from './useWorkspaceSessionDiscovery'; + +const page = (pending?: boolean): SessionMetadataPage => ({ + sessions: [], totalTopLevelCount: 107, loadedTopLevelCount: 5, hasMore: true, nextCursor: '5', discoveryPending: pending, +}); +let current: ReturnType; +function Harness({ visible = true }: { visible?: boolean }) { + current = useWorkspaceSessionDiscovery(['workspace'], visible, mocks.completed); + return null; +} + +describe('workspace discovery observation', () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + mocks.epoch = 0; + mocks.host = 'local'; + mocks.configListeners.clear(); + mocks.loadPage.mockResolvedValue(page(false)); + container = document.createElement('div'); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + const render = async (visible = true) => { await act(async () => root.render()); }; + const tick = async () => { await act(async () => { await vi.advanceTimersByTimeAsync(1500); }); }; + + it('does not poll legacy or idle hosts', async () => { + await render(); + await act(async () => current.observe('workspace', page())); + await tick(); + expect(mocks.loadPage).not.toHaveBeenCalled(); + }); + + it('starts discovery for an already loaded workspace when its source switch changes', async () => { + await render(); + await act(async () => current.observe('workspace', page(false))); + mocks.loadPage.mockResolvedValueOnce(page(true)).mockResolvedValueOnce(page(false)); + await act(async () => { + mocks.configListeners.forEach(callback => callback('app.external_session_discovery.codex')); + }); + expect(mocks.loadPage).toHaveBeenCalledWith('workspace', 5, undefined, 'sessions_nav_discovery_settings', { fresh: true }); + expect(current.pending).toBe(true); + await tick(); + expect(current.pending).toBe(false); + expect(mocks.completed).toHaveBeenCalledTimes(1); + }); + + it('refreshes filtered totals even when a settings-triggered scan finishes before the first response', async () => { + await render(); + await act(async () => current.observe('workspace', page(false))); + await act(async () => mocks.configListeners.forEach(callback => callback('app.external_session_discovery.codex'))); + expect(mocks.completed).toHaveBeenCalledExactlyOnceWith('workspace', expect.objectContaining({ totalTopLevelCount: 107 })); + await tick(); + expect(mocks.loadPage).toHaveBeenCalledTimes(1); + }); + + it('does not read hidden workspaces for unrelated settings and resumes a saved discovery change on reveal', async () => { + await render(false); + await act(async () => mocks.configListeners.forEach(callback => callback('app.language'))); + await render(); + expect(mocks.loadPage).not.toHaveBeenCalled(); + await render(false); + await act(async () => mocks.configListeners.forEach(callback => callback('app.external_session_discovery.codex'))); + expect(mocks.loadPage).not.toHaveBeenCalled(); + await render(); + expect(mocks.loadPage).toHaveBeenCalledTimes(1); + }); + + it('refreshes native metadata while discovery runs and reports the final pagination totals', async () => { + mocks.loadPage.mockResolvedValueOnce(page(true)).mockResolvedValueOnce(page(false)); + await render(); + await act(async () => current.observe('workspace', page(true))); + await tick(); + expect(current.pending).toBe(true); + expect(mocks.completed).not.toHaveBeenCalled(); + await tick(); + expect(current.pending).toBe(false); + expect(mocks.completed).toHaveBeenCalledWith('workspace', expect.objectContaining({ totalTopLevelCount: 107, hasMore: true })); + await tick(); + expect(mocks.loadPage).toHaveBeenCalledTimes(2); + }); + + it('pauses observation while hidden and resumes without cancelling host work', async () => { + await render(false); + await act(async () => current.observe('workspace', page(true))); + await tick(); + expect(mocks.loadPage).not.toHaveBeenCalled(); + await render(); + await tick(); + expect(mocks.completed).toHaveBeenCalledTimes(1); + }); + + it('reconciles completion observed by normal pagination before a polling request', async () => { + await render(); + await act(async () => { + current.observe('workspace', page(true)); + current.observe('workspace', page(false)); + current.observe('workspace', page(false)); + }); + expect(mocks.completed).toHaveBeenCalledTimes(1); + expect(current.pending).toBe(false); + await tick(); + expect(mocks.loadPage).not.toHaveBeenCalled(); + }); + + it('clears the previous host failure when the device changes', async () => { + mocks.loadPage.mockRejectedValueOnce(new Error('Disconnected')); + await render(); + await act(async () => current.observe('workspace', page(true))); + await tick(); + expect(current.failed).toBe(true); + mocks.host = 'peer'; + mocks.epoch += 1; + await render(); + expect(current.failed).toBe(false); + expect(current.pending).toBe(false); + }); + + it('isolates pending state and late responses across device switches', async () => { + let resolve!: (value: SessionMetadataPage) => void; + mocks.loadPage.mockReturnValue(new Promise(yes => { resolve = yes; })); + await render(); + await act(async () => current.observe('workspace', { ...page(true), discoveryWarnings: ['local issue'] })); + await tick(); + mocks.host = 'peer'; + mocks.epoch += 1; + await render(); + await act(async () => resolve(page(false))); + expect(current.pending).toBe(false); + expect(current.warnings).toEqual([]); + expect(mocks.completed).not.toHaveBeenCalled(); + await tick(); + expect(mocks.loadPage).toHaveBeenCalledTimes(1); + }); + + it('offers retry after a disconnect without an unbounded failing request loop', async () => { + mocks.loadPage.mockRejectedValueOnce(new Error('Disconnected')); + await render(); + await act(async () => current.observe('workspace', page(true))); + await tick(); + expect(current.failed).toBe(true); + await tick(); + expect(mocks.loadPage).toHaveBeenCalledTimes(1); + await act(async () => current.retry()); + await tick(); + expect(current.failed).toBe(false); + expect(current.pending).toBe(false); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.ts new file mode 100644 index 0000000000..bd1bdae2e3 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useWorkspaceSessionDiscovery.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; +import { getActiveSurfaceId, getActiveSurfaceScope, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('WorkspaceSessionDiscovery'); +interface DiscoveryState { pending: boolean; warnings: string[] } + +/** Watches only host-advertised in-progress work. Imports remain on the host + * when this observer is hidden, unmounted, disconnected or switched to a peer. */ +export function useWorkspaceSessionDiscovery( + workspaceIds: readonly string[], + visible: boolean, + onCompleted: (workspaceId: string, page: SessionMetadataPage) => void, +) { + const [states, setStates] = useState>({}); + const statesRef = useRef(states); + const [failed, setFailed] = useState(false); + const [retryVersion, setRetryVersion] = useState(0); + const [settingsVersion, setSettingsVersion] = useState(0); + const scopeKey = JSON.stringify(workspaceIds); + const scopedWorkspaceIds = useMemo(() => JSON.parse(scopeKey) as string[], [scopeKey]); + const surfaceId = getActiveSurfaceId(); + const observe = useCallback((workspaceId: string, page: SessionMetadataPage, refresh = false) => { + const key = surfaceScopedKey(surfaceId, workspaceId); + const next = { pending: page.discoveryPending === true, warnings: page.discoveryWarnings ?? [] }; + const previous = statesRef.current[key]; + if (previous?.pending !== next.pending || JSON.stringify(previous.warnings) !== JSON.stringify(next.warnings)) { + statesRef.current = { ...statesRef.current, [key]: next }; + setStates(statesRef.current); + } + // Any metadata read may observe completion before the polling request does. + // Reload from the first cursor so imports inserted ahead of a running page + // traversal cannot be omitted from a source filter or aggregate projection. + if (!next.pending && (previous?.pending || refresh)) onCompleted(workspaceId, page); + }, [onCompleted, surfaceId]); + const pendingKey = JSON.stringify(workspaceIds.filter(id => states[surfaceScopedKey(surfaceId, id)]?.pending)); + const warnings = useMemo(() => [...new Set(scopedWorkspaceIds.flatMap(id => states[surfaceScopedKey(surfaceId, id)]?.warnings ?? []))], [states, scopedWorkspaceIds, surfaceId]); + + useEffect(() => configManager.onConfigChange(path => { + if (path === 'app' || path === 'app.external_session_discovery' || path.startsWith('app.external_session_discovery.')) { + setSettingsVersion(version => version + 1); + } + }), []); + + useEffect(() => { + if (!settingsVersion || !visible || scopedWorkspaceIds.length === 0) return; + const scope = getActiveSurfaceScope(); + let cancelled = false; + const current = () => !cancelled && scope.isCurrent(); + // Saving the switch must wake already-mounted workspace rows. A completed + // page from before the save cannot satisfy this read through the store cache. + void Promise.allSettled(scopedWorkspaceIds.map(async workspaceId => { + const page = await flowChatStore.loadSessionMetadataPage(workspaceId, 5, undefined, 'sessions_nav_discovery_settings', { fresh: true }); + if (current()) observe(workspaceId, page, true); + })).then(results => { + if (current() && results.some(result => result.status === 'rejected')) setFailed(true); + }); + return () => { cancelled = true; }; + }, [observe, retryVersion, scopedWorkspaceIds, settingsVersion, visible]); + + useEffect(() => { + const pendingIds = JSON.parse(pendingKey) as string[]; + setFailed(false); + if (!visible || pendingIds.length === 0) return; + const scope = getActiveSurfaceScope(); + let cancelled = false; + let timer: ReturnType; + const current = () => !cancelled && scope.isCurrent(); + const refresh = async () => { + if (!current()) return; + const results = await Promise.allSettled(pendingIds.map(async workspaceId => { + const page = await flowChatStore.loadSessionMetadataPage(workspaceId, 5, undefined, 'sessions_nav_discovery'); + if (!current()) return; + observe(workspaceId, page); + })); + if (!current()) return; + const errors = results.filter(result => result.status === 'rejected'); + if (errors.length) { + log.warn('Failed to refresh external session discovery', { errors }); + setFailed(true); + return; + } + timer = setTimeout(() => void refresh(), 1500); + }; + timer = setTimeout(() => void refresh(), 1500); + return () => { cancelled = true; clearTimeout(timer); }; + }, [observe, pendingKey, retryVersion, visible]); + + return { + observe, + pending: pendingKey !== '[]', + warnings, + failed, + retry: () => setRetryVersion(value => value + 1), + }; +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionSourcePages.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionSourcePages.test.ts new file mode 100644 index 0000000000..770d1418d6 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionSourcePages.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; +import { loadWorkspaceSessionSourcePages } from './workspaceSessionSourcePages'; + +const page = (hasMore: boolean, nextCursor?: string): SessionMetadataPage => ({ + sessions: [], totalTopLevelCount: 107, loadedTopLevelCount: 50, hasMore, nextCursor, +}); + +describe('complete session metadata traversal for source filters', () => { + it('traverses all pages and preserves nonfatal discovery notices', async () => { + const load = vi.fn().mockResolvedValueOnce({ ...page(true, '50'), discoveryWarnings: ['partial'] }) + .mockResolvedValueOnce(page(true, '100')).mockResolvedValueOnce(page(false)); + expect(await loadWorkspaceSessionSourcePages(load, () => true)).toEqual(['partial']); + expect(load.mock.calls).toEqual([[undefined], ['50'], ['100']]); + }); + + it('does not issue another read after the source scope or serving device changes', async () => { + let current = true; + const load = vi.fn(async () => { current = false; return page(true, '50'); }); + await loadWorkspaceSessionSourcePages(load, () => current); + expect(load).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, 'repeated'])('reports a nonadvancing cursor (%s) instead of looping or pretending completion', async cursor => { + const load = vi.fn().mockResolvedValue(page(true, cursor)); + await expect(loadWorkspaceSessionSourcePages(load, () => true)).rejects.toThrow('did not advance'); + expect(load).toHaveBeenCalledTimes(cursor ? 2 : 1); + }); + + it('keeps read failures observable so the list can offer a retry', async () => { + const load = vi.fn().mockResolvedValueOnce(page(true, '50')).mockRejectedValueOnce(new Error('Disconnected')); + await expect(loadWorkspaceSessionSourcePages(load, () => true)).rejects.toThrow('Disconnected'); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionSourcePages.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionSourcePages.ts new file mode 100644 index 0000000000..06788b9de1 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/workspaceSessionSourcePages.ts @@ -0,0 +1,22 @@ +import type { SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; + +/** Workspace ownership and source filtering need the entire metadata catalog, + * including older hosts without scoped totals. No turns load. */ +export async function loadWorkspaceSessionSourcePages( + loadPage: (cursor?: string) => Promise, + isCurrent: () => boolean, +): Promise { + const warnings = new Set(); + const visited = new Set(); + let cursor: string | undefined; + while (isCurrent()) { + const page = await loadPage(cursor); + if (!isCurrent()) return []; + for (const warning of page.discoveryWarnings ?? []) warnings.add(warning); + if (!page.hasMore) break; + cursor = page.nextCursor; + if (!cursor || visited.has(cursor)) throw new Error('Session metadata pagination did not advance'); + visited.add(cursor); + } + return [...warnings]; +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceAcpSessionSubmenu.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceAcpSessionSubmenu.tsx index 8892840cdb..62385de966 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceAcpSessionSubmenu.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceAcpSessionSubmenu.tsx @@ -1,20 +1,10 @@ -import { - forwardRef, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, - type ForwardedRef, - type KeyboardEvent, - type MutableRefObject, -} from 'react'; +import { forwardRef } from 'react'; import { Loader2 } from 'lucide-react'; -import { createOverlayPortal, Icon, Menu, MenuItem } from '@openbitfun/ui'; +import { Icon, MenuItem } from '@openbitfun/ui'; import type { AcpClientInfo } from '@/infrastructure/api/service-api/ACPClientAPI'; -import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useI18n } from '@/infrastructure/i18n'; +import WorkspaceMenuSubmenu from './WorkspaceMenuSubmenu'; interface WorkspaceAcpSessionSubmenuProps { clients: readonly AcpClientInfo[]; @@ -22,203 +12,36 @@ interface WorkspaceAcpSessionSubmenuProps { onSelect: (client: AcpClientInfo) => void; } -interface SubmenuLayout { - left: number; - placement: 'left' | 'right'; - top: number; -} - -const SUBMENU_GAP = 5; -const SUBMENU_FALLBACK_WIDTH = 220; -const SUBMENU_FALLBACK_HEIGHT = 280; -const VIEWPORT_PADDING = 8; - -const clamp = (value: number, min: number, max: number): number => ( - Math.min(Math.max(value, min), Math.max(min, max)) -); - -function assignRef(ref: ForwardedRef, value: T | null): void { - if (typeof ref === 'function') { - ref(value); - } else if (ref) { - (ref as MutableRefObject).current = value; - } -} - const WorkspaceAcpSessionSubmenu = forwardRef( function WorkspaceAcpSessionSubmenu({ clients, loading, onSelect }, forwardedRef) { const { t } = useI18n('common'); - const [open, setOpen] = useState(false); - const [layout, setLayout] = useState(null); - const triggerRef = useRef(null); - const submenuRef = useRef(null); - const focusSubmenuOnOpenRef = useRef(false); - - const setSubmenuRef = useCallback((node: HTMLDivElement | null) => { - submenuRef.current = node; - assignRef(forwardedRef, node); - }, [forwardedRef]); - - const updateLayout = useCallback(() => { - const trigger = triggerRef.current; - const submenu = submenuRef.current; - if (!trigger || !submenu) return; - - const triggerRect = trigger.getBoundingClientRect(); - const submenuRect = submenu.getBoundingClientRect(); - const submenuWidth = submenuRect.width - || submenu.offsetWidth - || submenu.scrollWidth - || SUBMENU_FALLBACK_WIDTH; - const submenuHeight = submenuRect.height - || submenu.offsetHeight - || submenu.scrollHeight - || SUBMENU_FALLBACK_HEIGHT; - const preferredLeft = triggerRect.right + SUBMENU_GAP; - const opensRight = preferredLeft + submenuWidth <= window.innerWidth - VIEWPORT_PADDING; - const nextLayout: SubmenuLayout = { - left: clamp( - opensRight ? preferredLeft : triggerRect.left - SUBMENU_GAP - submenuWidth, - VIEWPORT_PADDING, - window.innerWidth - submenuWidth - VIEWPORT_PADDING, - ), - placement: opensRight ? 'right' : 'left', - top: clamp( - triggerRect.top - 4, - VIEWPORT_PADDING, - window.innerHeight - submenuHeight - VIEWPORT_PADDING, - ), - }; - - setLayout(current => current?.left === nextLayout.left - && current.top === nextLayout.top - && current.placement === nextLayout.placement - ? current - : nextLayout); - }, []); - - useLayoutEffect(() => { - if (!open) { - setLayout(null); - return; - } - - updateLayout(); - const frame = window.requestAnimationFrame(updateLayout); - const resizeObserver = typeof ResizeObserver === 'undefined' - ? null - : new ResizeObserver(updateLayout); - if (triggerRef.current) resizeObserver?.observe(triggerRef.current); - if (submenuRef.current) resizeObserver?.observe(submenuRef.current); - window.addEventListener('resize', updateLayout); - window.addEventListener('scroll', updateLayout, true); - - return () => { - window.cancelAnimationFrame(frame); - resizeObserver?.disconnect(); - window.removeEventListener('resize', updateLayout); - window.removeEventListener('scroll', updateLayout, true); - }; - }, [clients.length, loading, open, updateLayout]); - - const focusFirstSubmenuItem = useCallback(() => { - submenuRef.current - ?.querySelector('[data-openbitfun-menu-item]:not(:disabled)') - ?.focus(); - }, []); - - useEffect(() => { - if (!open || !focusSubmenuOnOpenRef.current) return; - focusSubmenuOnOpenRef.current = false; - const frame = window.requestAnimationFrame(focusFirstSubmenuItem); - return () => window.cancelAnimationFrame(frame); - }, [focusFirstSubmenuItem, open]); - - useEffect(() => { - if (!loading && clients.length === 0) setOpen(false); - }, [clients.length, loading]); - - const openFromKeyboard = useCallback(() => { - focusSubmenuOnOpenRef.current = true; - if (open) { - window.requestAnimationFrame(focusFirstSubmenuItem); - } else { - setOpen(true); - } - }, [focusFirstSubmenuItem, open]); - - const handleTriggerKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'ArrowRight') return; - event.preventDefault(); - event.stopPropagation(); - openFromKeyboard(); - }; - - const handleSubmenuKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'ArrowLeft' && event.key !== 'Escape') return; - event.preventDefault(); - event.stopPropagation(); - setOpen(false); - triggerRef.current?.focus(); - }; - if (!loading && clients.length === 0) return null; - const label = t('nav.sessions.acpSessions'); - return ( - <> - } - shortcut={ - - {open && createOverlayPortal( - } + triggerTestId="nav-workspace-menu-acp-sessions" + submenuTestId="nav-workspace-menu-acp-submenu" + layoutRevision={`${loading}:${clients.length}`} + > + {loading ? ( + + ) : clients.map(client => ( + } + onClick={() => onSelect(client)} + data-testid="nav-workspace-menu-create-acp-session" + data-acp-client-id={client.id} > - {loading ? ( - - ) : clients.map(client => { - const clientLabel = client.name || client.id; - return ( - } - onClick={() => onSelect(client)} - data-testid="nav-workspace-menu-create-acp-session" - data-acp-client-id={client.id} - > - {t('nav.sessions.newExternalAgentSessionShort', { agentName: clientLabel })} - - ); - })} - , - getAppearanceOverlayHost(), - )} - + {t('nav.sessions.newExternalAgentSessionShort', { agentName: client.name || client.id })} + + ))} + ); }, ); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index eb1db0372b..e8a7ff8c6c 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -51,6 +51,7 @@ import { import type { AcpClientInfo } from '@/infrastructure/api/service-api/ACPClientAPI'; import { loadWorkspaceAcpMenuClients } from './workspaceAcpMenuClients'; import WorkspaceAcpSessionSubmenu from './WorkspaceAcpSessionSubmenu'; +import WorkspaceSessionSourceFilter from './WorkspaceSessionSourceFilter'; import SessionsSection from '../sessions/SessionsSection'; import { useWorkspaceSessionViewStore } from '../../workspaceSessionView'; import { @@ -165,6 +166,7 @@ const WorkspaceItem: React.FC = ({ const menuAnchorRef = useRef(null); const menuPopoverRef = useRef(null); const acpSubmenuRef = useRef(null); + const sourceSubmenuRef = useRef(null); const cardRef = useRef(null); const menuPosition = useSideAnchoredPopoverPosition({ open: menuOpen, @@ -482,7 +484,8 @@ const WorkspaceItem: React.FC = ({ const isInsideTriggerArea = menuRef.current?.contains(target); const isInsidePopover = menuPopoverRef.current?.contains(target); const isInsideAcpSubmenu = acpSubmenuRef.current?.contains(target); - if (!isInsideTriggerArea && !isInsidePopover && !isInsideAcpSubmenu) { + const isInsideSourceSubmenu = sourceSubmenuRef.current?.contains(target); + if (!isInsideTriggerArea && !isInsidePopover && !isInsideAcpSubmenu && !isInsideSourceSubmenu) { setMenuOpen(false); } }; @@ -952,6 +955,7 @@ const WorkspaceItem: React.FC = ({ {t('ssh.portForward.menuEntry')} ) : null} + setMenuOpen(false)} /> } @@ -1463,6 +1467,7 @@ const WorkspaceItem: React.FC = ({ > {t('nav.sessions.manage')} + setMenuOpen(false)} /> } tone="danger" diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index 92d68fb0cf..c0867f51c8 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -915,11 +915,11 @@ animation: openbitfun-footer-menu-in var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-decelerate) forwards; } - &__workspace-acp-menu-trigger.is-open { + &__workspace-submenu-trigger.is-open { background: var(--openbitfun-color-action-neutral-surface); } - &__workspace-acp-submenu { + &__workspace-submenu { &[data-placement='right'] { transform-origin: top left; } @@ -1343,7 +1343,7 @@ @media (prefers-reduced-motion: reduce) { .openbitfun-nav-panel__workspace-item-menu-popover, - .openbitfun-nav-panel__workspace-acp-submenu { + .openbitfun-nav-panel__workspace-submenu { animation: none; } } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceMenuSubmenu.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceMenuSubmenu.tsx new file mode 100644 index 0000000000..5dced25416 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceMenuSubmenu.tsx @@ -0,0 +1,208 @@ +import { + forwardRef, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type FocusEvent, + type KeyboardEvent, + type ReactNode, +} from 'react'; +import { createOverlayPortal, Icon, Menu, MenuItem, subscribeOverlayInteraction } from '@openbitfun/ui'; + +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; + +interface WorkspaceMenuSubmenuProps { + label: string; + leading: ReactNode; + children: ReactNode; + triggerTestId: string; + submenuTestId: string; + layoutRevision?: string; +} + +interface SubmenuLayout { + left: number; + placement: 'left' | 'right'; + top: number; +} + +const SUBMENU_GAP = 5; +const SUBMENU_FALLBACK_WIDTH = 220; +const SUBMENU_FALLBACK_HEIGHT = 280; +const VIEWPORT_PADDING = 8; + +const clamp = (value: number, min: number, max: number): number => ( + Math.min(Math.max(value, min), Math.max(min, max)) +); + +const WorkspaceMenuSubmenu = forwardRef( + function WorkspaceMenuSubmenu({ label, leading, children, triggerTestId, submenuTestId, layoutRevision }, forwardedRef) { + const [open, setOpen] = useState(false); + const [layout, setLayout] = useState(null); + const triggerRef = useRef(null); + const submenuRef = useRef(null); + const focusSubmenuOnOpenRef = useRef(false); + const submenuId = useId(); + + const setSubmenuRef = useCallback((node: HTMLDivElement | null) => { + submenuRef.current = node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }, [forwardedRef]); + + const updateLayout = useCallback(() => { + const trigger = triggerRef.current; + const submenu = submenuRef.current; + if (!trigger || !submenu) return; + + const triggerRect = trigger.getBoundingClientRect(); + const submenuRect = submenu.getBoundingClientRect(); + const submenuWidth = submenuRect.width + || submenu.offsetWidth + || submenu.scrollWidth + || SUBMENU_FALLBACK_WIDTH; + const submenuHeight = submenuRect.height + || submenu.offsetHeight + || submenu.scrollHeight + || SUBMENU_FALLBACK_HEIGHT; + const preferredLeft = triggerRect.right + SUBMENU_GAP; + const opensRight = preferredLeft + submenuWidth <= window.innerWidth - VIEWPORT_PADDING; + const nextLayout: SubmenuLayout = { + left: clamp( + opensRight ? preferredLeft : triggerRect.left - SUBMENU_GAP - submenuWidth, + VIEWPORT_PADDING, + window.innerWidth - submenuWidth - VIEWPORT_PADDING, + ), + placement: opensRight ? 'right' : 'left', + top: clamp( + triggerRect.top - 4, + VIEWPORT_PADDING, + window.innerHeight - submenuHeight - VIEWPORT_PADDING, + ), + }; + + setLayout(current => current?.left === nextLayout.left + && current.top === nextLayout.top + && current.placement === nextLayout.placement + ? current + : nextLayout); + }, []); + + useLayoutEffect(() => { + if (!open) { + setLayout(null); + return; + } + + updateLayout(); + const frame = window.requestAnimationFrame(updateLayout); + const resizeObserver = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(updateLayout); + if (triggerRef.current) resizeObserver?.observe(triggerRef.current); + if (submenuRef.current) resizeObserver?.observe(submenuRef.current); + window.addEventListener('resize', updateLayout); + window.addEventListener('scroll', updateLayout, true); + + return () => { + window.cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateLayout); + window.removeEventListener('scroll', updateLayout, true); + }; + }, [layoutRevision, open, updateLayout]); + + const containsTarget = useCallback((target: Node | null) => Boolean(target + && (triggerRef.current?.contains(target) || submenuRef.current?.contains(target))), []); + + useEffect(() => { + if (!open) return; + return subscribeOverlayInteraction(triggerRef, 'mousedown', event => { + if (!containsTarget(event.target as Node | null)) setOpen(false); + }); + }, [containsTarget, open]); + + const handleBlur = (event: FocusEvent) => { + if (!containsTarget(event.relatedTarget)) setOpen(false); + }; + + const focusFirstSubmenuItem = useCallback(() => { + submenuRef.current + ?.querySelector('[data-openbitfun-menu-item]:not(:disabled)') + ?.focus(); + }, []); + + useEffect(() => { + if (!open || !focusSubmenuOnOpenRef.current) return; + focusSubmenuOnOpenRef.current = false; + const frame = window.requestAnimationFrame(focusFirstSubmenuItem); + return () => window.cancelAnimationFrame(frame); + }, [focusFirstSubmenuItem, open]); + + const handleTriggerKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'ArrowRight') return; + event.preventDefault(); + event.stopPropagation(); + focusSubmenuOnOpenRef.current = true; + if (open) window.requestAnimationFrame(focusFirstSubmenuItem); + else setOpen(true); + }; + + const handleSubmenuKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + triggerRef.current?.focus(); + }; + + return ( + <> + + + {open && createOverlayPortal( + + {children} + , + getAppearanceOverlayHost(), + undefined, + { ownerRef: triggerRef }, + )} + + ); + }, +); + +export default WorkspaceMenuSubmenu; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.test.tsx index b39a839dd3..cf4cefa844 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.test.tsx @@ -84,4 +84,27 @@ describe('Claw session batch management', () => { expect(mocks.confirmDanger).toHaveBeenCalledTimes(1); expect(mocks.deleteChatSession.mock.calls).toEqual([['claw-2'], ['claw-1']]); }); + + it.each(['remove', 'archive'])('%s affects the selected source without selecting its independent branch', async operation => { + const rows = await mocks.listSessions(); + mocks.listSessions.mockResolvedValue([ + { ...rows[0], customMetadata: { externalHistoryMode: 'follow', externalHistoryPending: true, importedFrom: 'codex' } }, + { ...rows[1], customMetadata: { externalBranchOf: { sessionId: 'claw-1', ecosystemId: 'codex' } } }, + ]); + await act(async () => root.render( + {}} workspaceLabel="Claw" />, + )); + const sourceLabel = [...container.querySelectorAll('label')].find(label => label.textContent?.includes('First assistant chat'))!; + await act(async () => sourceLabel.querySelector('input')!.click()); + if (operation === 'remove') { + await clickAction('nav.sessions.removeSelected'); + expect(mocks.confirmDanger).toHaveBeenCalledWith('nav.sessions.bulkRemoveSourceConfirmTitle', + 'nav.sessions.bulkRemoveSourceConfirmMessage', { confirmText: 'nav.sessions.removeSelected' }); + expect(mocks.deleteChatSession.mock.calls).toEqual([['claw-1']]); + } else { + await clickAction('nav.sessions.archiveSelected'); + expect(mocks.archiveChatSession.mock.calls).toEqual([['claw-1']]); + expect(mocks.confirmDanger).not.toHaveBeenCalled(); + } + }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx index a41aeda104..e9f8bb8613 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.tsx @@ -20,6 +20,7 @@ import { Archive } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import type { SessionMetadata } from '@/shared/types/session-history'; +import { externalHistoryFromMetadata } from '@/flow_chat/utils/externalHistory'; import { sessionBelongsToWorkspaceNavRow, compareSessionMetadataForDisplay } from '@/flow_chat/utils/sessionOrdering'; import { deriveSessionRelationshipFromMetadata, resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; @@ -249,10 +250,15 @@ const WorkspaceSessionBatchModal: React.FC = ({ if (selectedCount === 0) { return; } + const sourceCount = sessions.filter(item => selectedSessionIds.has(item.metadata.sessionId) + && externalHistoryFromMetadata(item.metadata.customMetadata)).length; + const onlySources = sourceCount === selectedCount; const confirmed = await confirmDanger( - t('nav.sessions.bulkDeleteConfirmTitle'), - t('nav.sessions.bulkDeleteConfirmMessage', { count: selectedCount }), - { confirmText: t('nav.sessions.deleteSelected') }, + onlySources ? t('nav.sessions.bulkRemoveSourceConfirmTitle') : t('nav.sessions.bulkDeleteConfirmTitle'), + onlySources ? t('nav.sessions.bulkRemoveSourceConfirmMessage', { count: selectedCount }) + : t('nav.sessions.bulkDeleteConfirmMessage', { count: selectedCount }) + + (sourceCount ? ` ${t('nav.sessions.removeSourceExplanation')}` : ''), + { confirmText: onlySources ? t('nav.sessions.removeSelected') : t('nav.sessions.deleteSelected') }, ); if (!confirmed) { return; @@ -279,7 +285,7 @@ const WorkspaceSessionBatchModal: React.FC = ({ if (successIds.size > 0) { await refreshWorkspaceSessions(); - notificationService.success(t('nav.sessions.deletedSelected', { count: successIds.size }), { duration: 3000 }); + notificationService.success(t(sourceCount ? 'nav.sessions.removedSelected' : 'nav.sessions.deletedSelected', { count: successIds.size }), { duration: 3000 }); } if (successIds.size !== deletionPlan.allIds.length) { notificationService.error(t('nav.sessions.bulkDeleteFailed'), { duration: 4000 }); @@ -460,7 +466,9 @@ const WorkspaceSessionBatchModal: React.FC = ({ loading={actionKind === 'delete'} leadingIcon={} > - {t('nav.sessions.deleteSelected')} + {t(selectedCount > 0 && sessions.filter(item => selectedSessionIds.has(item.metadata.sessionId)) + .every(item => !!externalHistoryFromMetadata(item.metadata.customMetadata)) + ? 'nav.sessions.removeSelected' : 'nav.sessions.deleteSelected')}
@@ -686,6 +693,12 @@ const EcosystemCompatibilityScene: React.FC = () => {
) : null} {selectedRuntime.acpClients.length > 0 ? renderRun() : null} + {selectedRuntime.spec.sessionImport ? : null}
{notice && !kind && !review && !undo && !batch && !batchUndo ? : null} {loading ? {t('loading')} : null} @@ -817,5 +818,6 @@ export default function ExternalAgentContent({ scopeKey, actionsRef, onActionSta : null}
+ ); } diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentSessions.test.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentSessions.test.tsx new file mode 100644 index 0000000000..5ff2c6b084 --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentSessions.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ getConfig: vi.fn(), setConfig: vi.fn(), epoch: 0 })); +vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ configAPI: mocks })); +vi.mock('./RemovedExternalSessions', () => ({ RemovedExternalSessions: () => null })); +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ configManager: { setConfig: mocks.setConfig } })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ + t: (key: string, values?: Record) => values ? key + ':' + JSON.stringify(values) : key, +}) })); +vi.mock('@/infrastructure/peer-device/deviceSurface', () => ({ getActiveSurfaceScope: () => { + const epoch = mocks.epoch; + return { isCurrent: () => epoch === mocks.epoch }; +} })); +vi.mock('@openbitfun/ui', () => ({ + Switch: (props: React.InputHTMLAttributes) => , + Alert: ({ message }: { message: React.ReactNode }) =>
{message}
, +})); +import ExternalAgentSessions from './ExternalAgentSessions'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} + +describe('external session automatic discovery toggle', () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + vi.resetAllMocks(); + mocks.epoch = 0; + mocks.getConfig.mockResolvedValue({}); + mocks.setConfig.mockResolvedValue(undefined); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + const toggle = () => container.querySelector('input')!; + const render = async (ecosystemId: 'codex' | 'zcode' = 'codex', key = 'host') => { + await act(async () => root.render()); + }; + + it('only exposes the host-owned opt-in switch and saves the selected source', async () => { + await render(); + expect(toggle().checked).toBe(false); + expect(toggle().disabled).toBe(false); + expect(container.querySelectorAll('button')).toHaveLength(0); + expect(mocks.getConfig).toHaveBeenCalledWith('app.external_session_discovery', { skipRetryOnNotFound: true }); + await act(async () => toggle().click()); + expect(mocks.setConfig).toHaveBeenCalledWith('app.external_session_discovery.codex', true); + expect(toggle().checked).toBe(true); + }); + + it('keeps source switches independent and lets the user disable discovery', async () => { + mocks.getConfig.mockResolvedValue({ codex: false, zcode: true }); + await render('zcode'); + expect(toggle().checked).toBe(true); + await act(async () => toggle().click()); + expect(mocks.setConfig).toHaveBeenCalledWith('app.external_session_discovery.zcode', false); + expect(toggle().checked).toBe(false); + }); + + it('keeps the confirmed preference after a failed save and supports retry', async () => { + mocks.setConfig.mockRejectedValueOnce(new Error('Offline')); + await render(); + await act(async () => toggle().click()); + expect(toggle().checked).toBe(false); + expect(container.textContent).toContain('sessions.autoDiscovery.saveFailed'); + await act(async () => toggle().click()); + expect(toggle().checked).toBe(true); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it('explicitly disables unsupported older hosts instead of storing a local flag', async () => { + mocks.getConfig.mockResolvedValue(undefined); + await render(); + expect(toggle().disabled).toBe(true); + expect(container.textContent).toContain('sessions.autoDiscovery.unsupported'); + expect(mocks.setConfig).not.toHaveBeenCalled(); + }); + + it('surfaces unreadable settings without enabling the switch', async () => { + mocks.getConfig.mockRejectedValue(new Error('Offline')); + await render(); + expect(toggle().disabled).toBe(true); + expect(container.textContent).toContain('sessions.autoDiscovery.loadFailed'); + }); + + it('ignores an old source read after selection changes', async () => { + const first = deferred>(); + mocks.getConfig.mockReturnValueOnce(first.promise).mockResolvedValueOnce({ zcode: false }); + await render('codex'); + await render('zcode'); + await act(async () => first.resolve({ codex: true, zcode: true })); + expect(toggle().checked).toBe(false); + expect(toggle().disabled).toBe(false); + }); + + it('does not project a save from the previous device onto the newly selected host', async () => { + const save = deferred(); + mocks.setConfig.mockReturnValueOnce(save.promise); + await render(); + await act(async () => toggle().click()); + expect(toggle().disabled).toBe(true); + mocks.epoch += 1; + await render('codex', 'peer'); + await act(async () => save.resolve()); + expect(toggle().checked).toBe(false); + expect(toggle().disabled).toBe(false); + expect(mocks.setConfig).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentSessions.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentSessions.tsx new file mode 100644 index 0000000000..23e0c5d4bd --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentSessions.tsx @@ -0,0 +1,93 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { Alert, Switch } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import type { ExternalSessionEcosystem } from '@/infrastructure/api/service-api/ExternalSessionImportAPI'; +import { RemovedExternalSessions } from './RemovedExternalSessions'; + +interface Props { + ecosystemId?: ExternalSessionEcosystem; + onBusyChange?: (busy: boolean) => void; + refreshVersion?: number; +} + +const CONFIG_PATH = 'app.external_session_discovery'; + +/** Only the serving host reads source history, when its workspace is opened. */ +export default function ExternalAgentSessions({ ecosystemId = 'codex', onBusyChange, refreshVersion = 0 }: Props) { + const { t } = useI18n('scenes/ecosystem-compatibility'); + const labelId = useId(); + const descriptionId = useId(); + const [enabled, setEnabled] = useState(false); + const [available, setAvailable] = useState(false); + const [busy, setBusy] = useState(false); + const [removedBusy, setRemovedBusy] = useState(false); + const [error, setError] = useState(); + const request = useRef(0); + const pending = useRef(false); + const sourceName = ecosystemId === 'zcode' ? 'Zcode' : 'Codex'; + + useEffect(() => { + const version = ++request.current; + const surface = getActiveSurfaceScope(); + const current = () => request.current === version && surface.isCurrent(); + setAvailable(false); + setBusy(true); + setError(undefined); + void configAPI.getConfig(CONFIG_PATH, { skipRetryOnNotFound: true }).then((settings) => { + if (!current()) return; + // Missing fields identify older hosts: never turn a local UI preference + // into an apparent capability on a peer that cannot perform discovery. + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + setError('sessions.autoDiscovery.unsupported'); + return; + } + setEnabled(settings[ecosystemId] === true); + setAvailable(true); + }).catch(() => { if (current()) setError('sessions.autoDiscovery.loadFailed'); }) + .finally(() => { if (current()) setBusy(false); }); + return () => { request.current += 1; }; + }, [ecosystemId, refreshVersion]); + + useEffect(() => { onBusyChange?.(busy || removedBusy); }, [busy, removedBusy, onBusyChange]); + useEffect(() => () => { onBusyChange?.(false); }, [onBusyChange]); + + async function change(next: boolean) { + if (!available || pending.current || busy) return; + const version = request.current; + const surface = getActiveSurfaceScope(); + const current = () => request.current === version && surface.isCurrent(); + pending.current = true; + setBusy(true); + setError(undefined); + try { + await configManager.setConfig(`${CONFIG_PATH}.${ecosystemId}`, next); + if (current()) setEnabled(next); + } catch { + if (current()) setError('sessions.autoDiscovery.saveFailed'); + } finally { + pending.current = false; + if (current()) setBusy(false); + } + } + + return ( +
+
+
+ +

{t('sessions.autoDiscovery.description', { name: sourceName })}

+
+ {error ? : null} +
+ +
+ ); +} diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/RemovedExternalSessions.test.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/RemovedExternalSessions.test.tsx new file mode 100644 index 0000000000..35c66aad11 --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/RemovedExternalSessions.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import { act, type ButtonHTMLAttributes, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activateSurface, LOCAL_SURFACE_ID } from '@/infrastructure/peer-device/deviceSurface'; +import { RemovedExternalSessions } from './RemovedExternalSessions'; + +const api = vi.hoisted(() => ({ listRemoved: vi.fn(), restoreRemoved: vi.fn(), refreshWorkspaceSessions: vi.fn() })); +vi.mock('@/infrastructure/api/service-api/ExternalSessionImportAPI', () => ({ externalSessionImportAPI: api })); +vi.mock('@/flow_chat/services/FlowChatManager', () => ({ flowChatManager: api })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('@openbitfun/ui', () => ({ + Button: ({ children, loading: _loading, variant: _variant, size: _size, ...props }: ButtonHTMLAttributes & { loading?: boolean; variant?: string; size?: string }) => , + Alert: ({ message }: { message: ReactNode }) =>
{message}
, + LoadingState: () =>
Loading
, + OverflowText: ({ children }: { children: ReactNode }) => {children}, + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +const removed = { ecosystemId: 'codex', sessionId: 'source', sessionName: 'Removed source', workspaceId: 'owner', workspaceName: 'Original workspace' }; +const busy = vi.fn(); + +describe('removed external source sessions', () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + vi.resetAllMocks(); + activateSurface(LOCAL_SURFACE_ID); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + api.listRemoved.mockResolvedValue([removed]); + api.restoreRemoved.mockResolvedValue(undefined); + api.refreshWorkspaceSessions.mockResolvedValue(undefined); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + activateSurface(LOCAL_SURFACE_ID); + vi.unstubAllGlobals(); + }); + const render = async (ecosystemId: 'codex' | 'zcode' = 'codex') => { + await act(async () => root.render()); + }; + const restore = async () => { await act(async () => container.querySelector('button')!.click()); }; + + it('restores only the chosen source in its owning workspace and refreshes its session list', async () => { + await render(); + expect(api.listRemoved).toHaveBeenCalledExactlyOnceWith('codex'); + expect(container.textContent).toContain('Original workspace'); + expect(api.restoreRemoved).not.toHaveBeenCalled(); + await restore(); + expect(api.restoreRemoved).toHaveBeenCalledExactlyOnceWith('source', 'owner', 'codex'); + expect(api.refreshWorkspaceSessions).toHaveBeenCalledExactlyOnceWith({ id: 'owner' }); + expect(container.textContent).not.toContain('Removed source'); + expect(container.textContent).toContain('sessions.removed.empty'); + }); + + it('keeps a failed restore visible and permits retry', async () => { + api.restoreRemoved.mockRejectedValueOnce(new Error('Disk is read only')); + await render(); + await restore(); + expect(container.textContent).toContain('Removed source'); + expect(container.textContent).toContain('sessions.removed.restoreFailed'); + expect(api.refreshWorkspaceSessions).not.toHaveBeenCalled(); + await restore(); + expect(container.textContent).toContain('sessions.removed.empty'); + }); + + it('reports a successful restore separately from a failed navigation refresh', async () => { + api.refreshWorkspaceSessions.mockRejectedValue(new Error('Offline')); + await render(); + await restore(); + expect(container.textContent).toContain('sessions.removed.refreshFailed'); + expect(container.textContent).not.toContain('Removed source'); + }); + + it('shows an explicit older-host limitation instead of an empty success', async () => { + api.listRemoved.mockRejectedValue({ code: 'incompatible_version' }); + await render(); + expect(container.textContent).toContain('sessions.removed.unsupportedHost'); + expect(container.textContent).not.toContain('sessions.removed.empty'); + }); + + it('does not read either host in Peer mode', async () => { + activateSurface('peer-removed-test'); + await render(); + expect(api.listRemoved).not.toHaveBeenCalled(); + expect(container.textContent).toContain('sessions.removed.unsupportedSurface'); + expect(container.querySelector('button')).toBeNull(); + }); + + it('discards an old source list after the selected ecosystem changes', async () => { + let resolve!: (entries: unknown[]) => void; + api.listRemoved.mockReturnValueOnce(new Promise(done => { resolve = done; })).mockResolvedValueOnce([]); + await render(); + await render('zcode'); + await act(async () => resolve([removed])); + expect(container.textContent).not.toContain('Removed source'); + expect(container.textContent).toContain('sessions.removed.empty'); + }); + + it('does not refresh another device when a restore finishes after a surface switch', async () => { + let resolve!: () => void; + api.restoreRemoved.mockReturnValueOnce(new Promise(done => { resolve = done; })); + await render(); + await restore(); + await act(async () => activateSurface('peer-removed-test')); + await act(async () => resolve()); + expect(api.refreshWorkspaceSessions).not.toHaveBeenCalled(); + expect(container.textContent).toContain('sessions.removed.unsupportedSurface'); + expect(container.textContent).not.toContain('Removed source'); + }); +}); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/RemovedExternalSessions.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/RemovedExternalSessions.tsx new file mode 100644 index 0000000000..1585dd5d83 --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/RemovedExternalSessions.tsx @@ -0,0 +1,97 @@ +import { useEffect, useId, useRef, useState, useSyncExternalStore } from 'react'; +import { Alert, Button, LoadingState, OverflowText, ScrollArea } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import { externalSessionImportAPI, type ExternalSessionEcosystem, type RemovedExternalSession } from '@/infrastructure/api/service-api/ExternalSessionImportAPI'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; + +interface Props { + ecosystemId: ExternalSessionEcosystem; + refreshVersion: number; + onBusyChange: (busy: boolean) => void; +} + +/** Lists only local removal records; history is read when a restored source opens. */ +export function RemovedExternalSessions({ ecosystemId, refreshVersion, onBusyChange }: Props) { + const { t } = useI18n('scenes/ecosystem-compatibility'); + const titleId = useId(); + const surface = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [restoring, setRestoring] = useState(); + const [error, setError] = useState(); + const [retry, setRetry] = useState(0); + const request = useRef(0); + const pending = useRef(false); + const unsupported = surface.surfaceId !== 'local'; + + useEffect(() => { + const version = ++request.current; + const current = () => version === request.current && surface.isCurrent(); + setEntries([]); + setError(undefined); + setRestoring(undefined); + setLoading(!unsupported); + if (unsupported) return; + void externalSessionImportAPI.listRemoved(ecosystemId).then(result => { + if (current()) setEntries(result); + }).catch(error => { + if (current()) setError(error?.code === 'incompatible_version' + ? 'sessions.removed.unsupportedHost' : 'sessions.removed.loadFailed'); + }).finally(() => { if (current()) setLoading(false); }); + return () => { request.current += 1; }; + }, [ecosystemId, refreshVersion, retry, surface, unsupported]); + + useEffect(() => { onBusyChange(loading || !!restoring); }, [loading, restoring, onBusyChange]); + useEffect(() => () => { onBusyChange(false); }, [onBusyChange]); + + async function restore(entry: RemovedExternalSession) { + if (pending.current || unsupported) return; + const version = request.current; + const current = () => version === request.current && surface.isCurrent(); + pending.current = true; + setRestoring(entry.sessionId); + setError(undefined); + try { + await externalSessionImportAPI.restoreRemoved(entry.sessionId, entry.workspaceId, ecosystemId); + if (!current()) return; + setEntries(previous => previous.filter(item => item.sessionId !== entry.sessionId || item.workspaceId !== entry.workspaceId)); + try { + const { flowChatManager } = await import('@/flow_chat/services/FlowChatManager'); + if (!current()) return; + await flowChatManager.refreshWorkspaceSessions({ id: entry.workspaceId }); + } catch { + if (current()) setError('sessions.removed.refreshFailed'); + } + } catch { + if (current()) setError('sessions.removed.restoreFailed'); + } finally { + pending.current = false; + if (current()) setRestoring(undefined); + } + } + + return
+
+

{t('sessions.removed.title')}

+

{t('sessions.removed.description')}

+
+ {unsupported ? : <> + {error && } + {loading ? : entries.length === 0 && !error + ?

{t('sessions.removed.empty')}

+ : + {entries.map(entry =>
+
+ {entry.sessionName} + {entry.workspaceName} +
+ +
)} +
} + {!loading && error && } + } +
; +} diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemCompatibilityModel.ts b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemCompatibilityModel.ts index 7581f31027..0f4ea9f801 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemCompatibilityModel.ts +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemCompatibilityModel.ts @@ -1,11 +1,13 @@ import { contentUsageState, type ContentUsageState } from './ecosystemContentPresentation'; import type { AcpClientInfo } from '@/infrastructure/api/service-api/ACPClientAPI'; import type { ExternalSourceCatalogSnapshot } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; +import type { ExternalSessionEcosystem } from '@/infrastructure/api/service-api/ExternalSessionImportAPI'; export type EcosystemProductId = | 'cursor' | 'claude-code' | 'codex' + | 'zcode' | 'pi' | 'dsh' | 'opencode'; @@ -24,6 +26,7 @@ export interface EcosystemProductSpec { name: string; ecosystemId: string; acpClientId?: string; + sessionImport?: ExternalSessionEcosystem; development?: boolean; searchTerms: readonly string[]; } @@ -116,6 +119,7 @@ const PRODUCT_DISCOVERY_KINDS = { cursor: ['skill'], 'claude-code': ['command', 'subagent', 'skill', 'mcp', 'hook'], codex: ['subagent', 'skill', 'mcp', 'hook', 'pet'], + zcode: [], pi: ['skill', 'hook'], dsh: ['skill', 'hook', 'mcp'], opencode: ['command', 'tool', 'subagent', 'skill', 'mcp', 'hook'], @@ -125,6 +129,7 @@ const PRODUCT_NOT_APPLICABLE_KINDS = { cursor: ECOSYSTEM_IMPORT_ITEM_KINDS.filter((kind) => kind !== 'skill'), 'claude-code': ['tool', 'pet'], codex: ['command', 'tool'], + zcode: ['tool', 'pet'], pi: ['pet'], dsh: ['pet'], opencode: ['pet'], @@ -146,8 +151,8 @@ export function ecosystemDiscoverySupport( } /** - * Presentation catalog for product families already represented by a shipped - * adapter or ACP preset. Skill and Hook discovery does not imply ACP execution. + * Presentation catalog for product families represented by a shipped adapter or + * ACP preset. Session import and static discovery do not imply ACP execution. */ export const ECOSYSTEM_PRODUCT_SPECS: readonly EcosystemProductSpec[] = [ { @@ -162,8 +167,16 @@ export const ECOSYSTEM_PRODUCT_SPECS: readonly EcosystemProductSpec[] = [ name: 'Codex', ecosystemId: 'codex', acpClientId: 'codex', + sessionImport: 'codex', searchTerms: ['openai', 'agent', 'mcp', 'acp'], }, + { + id: 'zcode', + name: 'Zcode', + ecosystemId: 'zcode', + sessionImport: 'zcode', + searchTerms: ['z.ai', 'zhipu', 'glm', 'agent', 'session'], + }, { id: 'pi', name: 'Pi', diff --git a/src/web-ui/src/app/scenes/settings/pages/data/ArchivedSessionsSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/data/ArchivedSessionsSettingsPage.tsx index 7dc50f9fb1..da29529fbb 100644 --- a/src/web-ui/src/app/scenes/settings/pages/data/ArchivedSessionsSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/data/ArchivedSessionsSettingsPage.tsx @@ -24,6 +24,7 @@ import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext' import { i18nService, useI18n } from '@/infrastructure/i18n'; import { notificationService } from '@/shared/notification-system'; import type { SessionMetadata } from '@/shared/types/session-history'; +import { externalHistoryFromMetadata } from '@/flow_chat/utils/externalHistory'; import { createLogger } from '@/shared/utils/logger'; import { Button, Empty, Icon, OverflowText } from '@openbitfun/ui'; import { Inbox, RotateCcw } from 'lucide-react'; @@ -112,6 +113,7 @@ const ArchivedRow: React.FC = ({ t, }) => { const { session } = entry; + const sourceView = !!externalHistoryFromMetadata(session.customMetadata); const displayName = session.sessionName || t('nav.sessions.untitled'); const dateStr = formatDateTime(session.lastActiveAt); @@ -144,9 +146,9 @@ const ArchivedRow: React.FC = ({ disabled={disabled} loading={pendingAction?.entryKey === archivedEntryIdentityKey(entry) && pendingAction.type === 'delete'} - aria-label={t('nav.sessions.deleteArchived')} + aria-label={t(sourceView ? 'nav.sessions.removeSource' : 'nav.sessions.deleteArchived')} > - {t('nav.sessions.deleteArchived')} + {t(sourceView ? 'nav.sessions.removeSource' : 'nav.sessions.deleteArchived')}
@@ -346,9 +348,10 @@ const ArchivedSessionsSettingsPage: React.FC = () => { const handleDelete = useCallback(async (entry: ArchivedEntry) => { if (pendingAction || bulkDeleting) return; + const sourceView = !!externalHistoryFromMetadata(entry.session.customMetadata); const confirmed = await confirmDanger( - t('nav.sessions.deleteArchivedConfirmTitle'), - t('nav.sessions.deleteArchivedConfirmMessage', { + t(sourceView ? 'nav.sessions.removeSourceConfirmTitle' : 'nav.sessions.deleteArchivedConfirmTitle'), + t(sourceView ? 'nav.sessions.removeSourceConfirmMessage' : 'nav.sessions.deleteArchivedConfirmMessage', { name: entry.session.sessionName || t('nav.sessions.untitled'), workspace: workspaceScopeLabel( entry.workspaceName, @@ -356,6 +359,7 @@ const ArchivedSessionsSettingsPage: React.FC = () => { entry.remoteSshHost, ), }), + { confirmText: t(sourceView ? 'nav.sessions.removeSource' : 'nav.sessions.deleteArchived') }, ); if (!confirmed) return; @@ -363,7 +367,7 @@ const ArchivedSessionsSettingsPage: React.FC = () => { try { await sessionAPI.deleteSession(entry.session.sessionId, entry.workspaceId); removeEntry(entry); - notificationService.success(t('nav.sessions.deleteArchivedSucceeded', { + notificationService.success(t(sourceView ? 'nav.sessions.removedSourceSucceeded' : 'nav.sessions.deleteArchivedSucceeded', { name: entry.session.sessionName || t('nav.sessions.untitled'), workspace: workspaceScopeLabel( entry.workspaceName, @@ -386,13 +390,16 @@ const ArchivedSessionsSettingsPage: React.FC = () => { const handleDeleteAll = useCallback(async () => { if (pendingAction || bulkDeleting || entries.length === 0) return; + const sourceCount = entries.filter(entry => externalHistoryFromMetadata(entry.session.customMetadata)).length; + const onlySources = sourceCount === entries.length; const workspaceCount = new Set(entries.map(entry => workspaceIdentityKey(entry.workspaceId))).size; const confirmed = await confirmDanger( - t('nav.sessions.deleteAllArchivedConfirmTitle'), - t('nav.sessions.deleteAllArchivedConfirmMessage', { + t(onlySources ? 'nav.sessions.bulkRemoveSourceConfirmTitle' : 'nav.sessions.deleteAllArchivedConfirmTitle'), + t(onlySources ? 'nav.sessions.bulkRemoveSourceConfirmMessage' : 'nav.sessions.deleteAllArchivedConfirmMessage', { count: entries.length, workspaceCount, - }), + }) + (sourceCount && !onlySources ? ` ${t('nav.sessions.removeSourceExplanation')}` : ''), + { confirmText: t(onlySources ? 'nav.sessions.removeSelected' : 'nav.sessions.deleteAllArchived') }, ); if (!confirmed) return; @@ -425,7 +432,7 @@ const ArchivedSessionsSettingsPage: React.FC = () => { ))); } if (failedWorkspaceCount === 0) { - notificationService.success(t('nav.sessions.deleteAllArchivedSucceeded', { + notificationService.success(t(sourceCount ? 'nav.sessions.removedSelected' : 'nav.sessions.deleteAllArchivedSucceeded', { count: deletedCount, })); } else if (deletedWorkspaceKeys.size > 0) { @@ -470,7 +477,8 @@ const ArchivedSessionsSettingsPage: React.FC = () => { disabled={pendingAction !== null || bulkDeleting} loading={bulkDeleting} > - {t('nav.sessions.deleteAllArchived')} + {t(entries.length > 0 && entries.every(entry => !!externalHistoryFromMetadata(entry.session.customMetadata)) + ? 'nav.sessions.removeSelected' : 'nav.sessions.deleteAllArchived')} )}
diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 667998bc5f..f5b4c62b42 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -71,6 +71,8 @@ import { modeReducer, initialModeState } from '../reducers/modeReducer'; import { CHAT_INPUT_CONFIG } from '../constants/chatInputConfig'; import { useMessageSender } from '../hooks/useMessageSender'; import { useChatInputState } from '../store/chatInputStateStore'; +import { ExternalHistoryNotice } from './ExternalHistoryNotice'; +import { readComposerPermissionMode } from '../utils/externalHistory'; import { useInputHistoryStore } from '../store/inputHistoryStore'; import { isPromptSuggestionAcceptKey, @@ -2530,14 +2532,17 @@ export const ChatInput: React.FC = ({ const permissionSessionId = isBtwDraftTarget ? effectiveTargetSession?.parentSessionId : effectiveTargetSessionId; if (!permissionSessionId) return; - const response = await agentAPI.getSessionPermissionMode({ + const response = await readComposerPermissionMode({ + externalHistory: effectiveTargetSession?.externalHistory, + externalContinuationPermissionMode: effectiveTargetSession?.externalContinuationPermissionMode, + }, () => agentAPI.getSessionPermissionMode({ sessionId: permissionSessionId, turnId: activePermissionTurnId ?? undefined, workspaceId: sessionOwningId, workspacePath: sessionOwningPath, remoteConnectionId: effectiveTargetSession?.remoteConnectionId, remoteSshHost: effectiveTargetSession?.remoteSshHost, - }); + })); if (permissionModeRequestGenerationRef.current !== generation) return; setSessionPermissionMode(response.mode ?? null); setSessionPermissionModeUnread(false); @@ -2575,6 +2580,8 @@ export const ChatInput: React.FC = ({ effectiveTargetSession?.remoteSshHost, effectiveTargetSession?.parentSessionId, effectiveTargetSession?.draft, + effectiveTargetSession?.externalHistory, + effectiveTargetSession?.externalContinuationPermissionMode, isBtwDraftTarget, isAcpTargetSession, t, @@ -2590,6 +2597,19 @@ export const ChatInput: React.FC = ({ const targetSessionId = effectiveTargetSessionId; const store = FlowChatStore.getInstance(); const draftSession = store.getState().sessions.get(targetSessionId); + if (draftSession?.externalHistory?.mode === 'follow') { + store.setState(state => { + const current = state.sessions.get(targetSessionId); + return current?.externalHistory?.mode === 'follow' + ? { ...state, sessions: new Map(state.sessions).set(targetSessionId, { + ...current, externalContinuationPermissionMode: nextMode, + }) } : state; + }); + setSessionPermissionMode(nextMode); + setArmedTurnPermissionMode(null); + setActiveTurnPermissionMode(null); + return; + } if (draftSession?.draft) { if (workspaceSubmittingRef.current || draftSession.draft.phase !== 'editing') return; store.setState(state => { @@ -6443,6 +6463,7 @@ export const ChatInput: React.FC = ({ data-testid="chat-input-container" aria-busy={caps.transferInFlight} > + {recommendationContext && ( ({ restore: vi.fn(), metadata: vi.fn(), load: vi.fn(), ensure: vi.fn(), open: vi.fn(), listRemoved: vi.fn(), restoreRemoved: vi.fn() })); +vi.mock('@/infrastructure/api/service-api/ExternalSessionImportAPI', () => ({ externalSessionImportAPI: api })); +vi.mock('@/infrastructure/api', () => ({ agentAPI: { restoreSessionView: api.restore }, sessionAPI: { loadSessionMetadata: api.metadata } })); +vi.mock('../store/FlowChatStore', () => ({ flowChatStore: { loadSessionHistory: api.load, ensurePersistedSessionMetadata: api.ensure } })); +vi.mock('../services/sessionActivation', () => ({ openMainSession: api.open })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('@openbitfun/ui', () => ({ + Button: ({ children, onClick, disabled }: { children: ReactNode; onClick: () => void; disabled: boolean }) => + , +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const source = () => ({ + sessionId: 'source', workspaceId: 'workspace', config: {}, historyState: 'ready', + importedSource: 'codex', externalHistory: { mode: 'follow', contentVersion: 'viewed' }, dialogTurns: [], +} as unknown as Session); +const branch = () => ({ ...source(), sessionId: 'branch', externalHistory: undefined, + externalBranchOf: { sessionId: 'source', ecosystemId: 'codex', sourceSessionId: 'original', contentVersion: 'viewed' } }); + +describe('ExternalHistoryNotice', () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + vi.useFakeTimers(); + vi.resetAllMocks(); + activateSurface(LOCAL_SURFACE_ID); + api.ensure.mockResolvedValue(true); + api.listRemoved.mockResolvedValue([]); + api.restoreRemoved.mockResolvedValue(undefined); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.restoreAllMocks(); + activateSurface(LOCAL_SURFACE_ID); + }); + const render = async (session: Session = source(), active = true) => { + await act(async () => { root.render(); }); + }; + + it.each(['source', 'branch'])('never refreshes the %s on render, visibility changes, activation or elapsed time', async kind => { + const session = kind === 'source' ? source() : branch(); + await render(session); + await render(session, false); + await render(session, true); + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(120_000); + }); + expect(api.restore).not.toHaveBeenCalled(); + expect(api.metadata).not.toHaveBeenCalled(); + expect(api.load).not.toHaveBeenCalled(); + expect(api.ensure).not.toHaveBeenCalled(); + if (kind === 'source') expect(container.querySelector('button')).toBeNull(); + }); + + it('opens a branch source only when explicitly requested', async () => { + await render(branch()); + expect(container.textContent).toContain('externalHistory.branchHint'); + await act(async () => { container.querySelector('button')!.click(); }); + expect(api.open).toHaveBeenCalledExactlyOnceWith('source', { workspaceId: 'workspace' }); + expect(api.restore).not.toHaveBeenCalled(); + }); + + it.each(['device', 'inactive', 'different-session'])('discards a source open after %s changes', async change => { + let resolve!: (found: boolean) => void; + api.ensure.mockReturnValueOnce(new Promise(done => { resolve = done; })); + await render(branch()); + await act(async () => { container.querySelector('button')!.click(); }); + if (change === 'device') await act(async () => { activateSurface('peer-source-test'); }); + if (change === 'inactive') await render(branch(), false); + if (change === 'different-session') await render({ ...branch(), sessionId: 'another' }); + await act(async () => { resolve(true); }); + expect(api.open).not.toHaveBeenCalled(); + }); + + it('explicitly gates source following on a peer without reading either host', async () => { + activateSurface('peer-source-test'); + await render(branch()); + expect(container.textContent).toContain('externalHistory.unsupportedSurface'); + expect(container.querySelector('button')!.disabled).toBe(true); + expect(api.ensure).not.toHaveBeenCalled(); + }); + + it('allows retry after reactivation and ignores the previous activation response', async () => { + let resolve!: (found: boolean) => void; + api.ensure.mockReturnValueOnce(new Promise(done => { resolve = done; })); + await render(branch()); + await act(async () => container.querySelector('button')!.click()); + expect(container.querySelector('button')!.disabled).toBe(true); + await render(branch(), false); + await render(branch()); + expect(container.querySelector('button')!.disabled).toBe(false); + await act(async () => container.querySelector('button')!.click()); + expect(api.open).toHaveBeenCalledTimes(1); + await act(async () => resolve(true)); + expect(api.open).toHaveBeenCalledTimes(1); + expect(container.querySelector('button')!.disabled).toBe(false); + }); + + it('shows unavailable cached history without retrying the source', async () => { + const session = source(); + session.externalHistory!.sourceStatus = 'unavailable'; + await render(session); + expect(container.textContent).toContain('externalHistory.unavailable'); + expect(api.restore).not.toHaveBeenCalled(); + }); + + it('explains a removed source and restores it only after an explicit second click', async () => { + api.ensure.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + api.listRemoved.mockResolvedValue([{ ecosystemId: 'codex', sessionId: 'source', workspaceId: 'workspace' }]); + await render(branch()); + await act(async () => container.querySelector('button')!.click()); + expect(container.textContent).toContain('externalHistory.removed'); + expect(container.querySelector('button')!.textContent).toBe('externalHistory.restoreSource'); + expect(api.restoreRemoved).not.toHaveBeenCalled(); + expect(api.open).not.toHaveBeenCalled(); + await act(async () => container.querySelector('button')!.click()); + expect(api.restoreRemoved).toHaveBeenCalledExactlyOnceWith('source', 'workspace', 'codex'); + expect(api.open).toHaveBeenCalledExactlyOnceWith('source', { workspaceId: 'workspace' }); + expect(api.restore).not.toHaveBeenCalled(); + }); + + it('reports a missing source without presenting the branch as a cached source view', async () => { + api.ensure.mockResolvedValue(false); + await render(branch()); + await act(async () => container.querySelector('button')!.click()); + expect(container.textContent).toContain('externalHistory.sourceMissing'); + expect(container.textContent).not.toContain('externalHistory.unavailable'); + expect(api.open).not.toHaveBeenCalled(); + }); + + it('keeps an explicit restore available after a failure', async () => { + api.ensure.mockResolvedValue(false); + api.listRemoved.mockResolvedValue([{ ecosystemId: 'codex', sessionId: 'source', workspaceId: 'workspace' }]); + api.restoreRemoved.mockRejectedValueOnce(new Error('Read only filesystem')); + await render(branch()); + await act(async () => container.querySelector('button')!.click()); + await act(async () => container.querySelector('button')!.click()); + expect(container.textContent).toContain('externalHistory.restoreFailed'); + expect(container.querySelector('button')!.disabled).toBe(false); + expect(api.open).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/ExternalHistoryNotice.tsx b/src/web-ui/src/flow_chat/components/ExternalHistoryNotice.tsx new file mode 100644 index 0000000000..b1e56516a4 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ExternalHistoryNotice.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { Button } from '@openbitfun/ui'; +import { useI18n } from '@/infrastructure/i18n'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { createLogger } from '@/shared/utils/logger'; +import { externalSessionImportAPI, type RemovedExternalSession } from '@/infrastructure/api/service-api/ExternalSessionImportAPI'; +import { openMainSession } from '../services/sessionActivation'; +import { flowChatStore } from '../store/FlowChatStore'; +import type { Session } from '../types/flow-chat'; +import './ExternalHistoryNotice.scss'; + +const log = createLogger('ExternalHistoryNotice'); + +/** Source IO belongs to session opening. Rendering this notice never refreshes it. */ +export function ExternalHistoryNotice({ session, active }: { session?: Session; active: boolean }) { + const { t } = useI18n('flow-chat'); + const surface = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope); + const followed = session?.externalHistory?.mode === 'follow'; + const sourceId = followed ? session.sessionId : session?.externalBranchOf?.sessionId; + const workspaceId = session?.workspaceId ?? session?.config.workspaceId; + const sourceName = (followed ? session?.importedSource : session?.externalBranchOf?.ecosystemId) ?? ''; + const [feedback, setFeedback] = useState<{ key: string; message: string; removed?: RemovedExternalSession }>(); + const [busyKey, setBusyKey] = useState(); + const pending = useRef(false); + const activation = useRef(0); + const current = useRef({ session, active, sourceId }); + current.current = { session, active, sourceId }; + useEffect(() => () => { current.current = { session: undefined, active: false, sourceId: undefined }; }, []); + useEffect(() => { + pending.current = false; + setBusyKey(undefined); + return () => { activation.current += 1; }; + }, [surface.epoch, session?.sessionId, sourceId, active]); + + if (!sourceId) return null; + const unsupportedSurface = surface.surfaceId !== 'local'; + const key = JSON.stringify([surface.epoch, session?.sessionId, sourceId]); + const notice = feedback?.key === key ? feedback : undefined; + const missing = session?.externalHistory?.sourceStatus === 'unavailable'; + const source = sourceName === 'codex' ? 'Codex' : sourceName === 'zcode' ? 'Zcode' : sourceName; + + async function openSource() { + if (!sourceId || !workspaceId || unsupportedSurface || !active || pending.current) return; + const scope = getActiveSurfaceScope(); + const requestActivation = activation.current; + const ownerId = session?.sessionId; + const isCurrent = () => requestActivation === activation.current && scope.isCurrent() && current.current.active + && current.current.session?.sessionId === ownerId && current.current.sourceId === sourceId; + pending.current = true; + setBusyKey(key); + try { + if (notice?.removed) { + try { + await externalSessionImportAPI.restoreRemoved(sourceId, workspaceId, notice.removed.ecosystemId); + } catch (error) { + if (isCurrent()) setFeedback({ key, message: 'externalHistory.restoreFailed', removed: notice.removed }); + log.debug('Removed source view could not be restored', { sourceId, error }); + return; + } + if (!isCurrent()) return; + } + const found = await flowChatStore.ensurePersistedSessionMetadata(sourceId, workspaceId, true); + if (!isCurrent()) return; + if (!found) { + const removed = sourceName === 'codex' || sourceName === 'zcode' + ? (await externalSessionImportAPI.listRemoved(sourceName)).find(entry => + entry.sessionId === sourceId && entry.workspaceId === workspaceId) + : undefined; + if (isCurrent()) setFeedback({ key, removed, + message: removed ? 'externalHistory.removed' : 'externalHistory.sourceMissing' }); + return; + } + setFeedback(undefined); + await openMainSession(sourceId, { workspaceId }); + } catch (error) { + if (isCurrent()) setFeedback({ key, message: 'externalHistory.openFailed' }); + log.debug('External source view could not be opened', { sourceId, error }); + } finally { + if (requestActivation === activation.current) pending.current = false; + if (isCurrent()) setBusyKey(undefined); + } + } + + return
+ {t(followed ? 'externalHistory.followHint' : 'externalHistory.branchHint', { source })} + {unsupportedSurface && {t('externalHistory.unsupportedSurface')}} + {missing && {t(session?.externalHistory?.contentVersion + ? 'externalHistory.unavailable' : 'externalHistory.unavailableWithoutCache')}} + {notice && {t(notice.message)}} + {!missing && session?.externalHistory?.incomplete && {t('externalHistory.incomplete')}} + {!followed && } +
; +} diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx index 01f2b0ad96..c8da42a764 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx @@ -20,15 +20,21 @@ vi.mock('../tool-cards', async () => { getToolCardComponent: (toolName: string) => ({ toolItem, isLastItem, + onOpenInEditor, + onOpenInPanel, }: { toolItem: FlowToolItem; isLastItem?: boolean; + onOpenInEditor?: unknown; + onOpenInPanel?: unknown; }) => { const capsule = useToolCapsulePresentation(); return ReactModule.createElement('div', { 'data-selected-card': toolName, 'data-card-tool-name': toolItem.toolName, 'data-is-last-item': String(isLastItem === true), + 'data-input': JSON.stringify(toolItem.toolCall.input), + 'data-has-file-actions': String(Boolean(onOpenInEditor || onOpenInPanel)), 'data-capsule-has-fallback': String(Boolean(capsule?.fallbackContent)), }); }, @@ -41,6 +47,12 @@ vi.mock('./FlowToolCardErrorBoundary', () => ({ vi.mock('./ToolApprovalBar', () => ({ ToolApprovalBar: () => null })); +vi.mock('../tool-cards/DefaultToolCard', () => ({ + DefaultToolCard: ({ toolItem }: { toolItem: FlowToolItem }) => ( +
+ ), +})); + const permissionContextMock = vi.hoisted(() => ({ pendingPermissionToolCallIds: new Set(), })); @@ -70,6 +82,51 @@ describe('FlowToolCard deferred identity', () => { permissionContextMock.pendingPermissionToolCallIds.clear(); }); + it('reuses the native Read card on hydration without granting imported calls a native approval', () => { + const item: FlowToolItem = { + id: 'imported', type: 'tool', toolName: 'Read', status: 'error', timestamp: 1, + toolCall: { id: 'imported', input: { file_path: '/remote/a' } }, + toolResult: { success: false, error: 'failed', result: 'partial output' }, + }; + act(() => root.render()); + expect(container.querySelector('.flow-tool-card-wrapper')).toBeNull(); + permissionContextMock.pendingPermissionToolCallIds.add('imported'); + act(() => root.render()); + expect(container.querySelector('[data-selected-card="Read"]')).not.toBeNull(); + expect(container.querySelector('.flow-tool-card-wrapper--permission-pending')).toBeNull(); + expect(container.querySelector('[data-card-tool-name="Read"]')).not.toBeNull(); + }); + + it.each([ + ['codex', 'exec', 'RunCode'], ['codex', 'exec_command', 'ExecCommand'], + ['codex', 'write_stdin', 'WriteStdin'], ['zcode', 'Bash', 'ExecCommand'], + ['zcode', 'Read', 'Read'], ['zcode', 'Write', 'Write'], ['zcode', 'Edit', 'Edit'], + ['zcode', 'WebSearch', 'WebSearch'], ['zcode', 'WebFetch', 'WebFetch'], + ['codex', 'update_plan', 'TodoWrite'], + ] as const)('selects the registered %s %s native card', (importedSource, toolName, nativeName) => { + const item: FlowToolItem = { + id: 'imported', type: 'tool', importedSource, toolName, status: 'completed', timestamp: 1, + toolCall: { id: 'imported', input: toolName === 'exec' ? 'text(1)' : { command: 'pwd' } }, + toolResult: { success: true, result: 'done' }, + }; + act(() => root.render()); + const card = container.querySelector(`[data-selected-card="${nativeName}"]`); + expect(card).not.toBeNull(); + expect(card?.getAttribute('data-has-file-actions')).toBe('false'); + if (nativeName === 'RunCode') expect(JSON.parse(card!.getAttribute('data-input')!).code).toBe('text(1)'); + if (nativeName === 'ExecCommand') expect(JSON.parse(card!.getAttribute('data-input')!).cmd).toBe('pwd'); + }); + + it.each(['unknown', 'mcp__example__exec', 'clock.sleep', 'functions.wait', 'apply_patch', 'AskUserQuestion'])('uses the common card for unmatched imported %s', toolName => { + const item: FlowToolItem = { + id: 'unmatched', type: 'tool', importedSource: 'codex', toolName, status: 'completed', timestamp: 1, + toolCall: { id: 'unmatched', input: {} }, toolResult: { success: true, result: 'recorded output' }, + }; + act(() => root.render()); + expect(container.querySelector('[data-selected-card="DefaultToolCard"]')).not.toBeNull(); + expect(container.querySelector('[data-original-tool]')?.getAttribute('data-original-tool')).toBe(toolName); + }); + it('switches to the effective card once the deferred tool name is available', () => { const base: FlowToolItem = { id: 'tool-1', diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx index ddbc2fdce9..9fcb88f928 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx @@ -7,6 +7,8 @@ import React from 'react'; import { FlowChatCardReaderProvider } from '../timeline/readerState'; import { ToolCapsulePresentationProvider } from '@openbitfun/ui/flow-chat'; import { getToolCardComponent } from '../tool-cards'; +import { DefaultToolCard } from '../tool-cards/DefaultToolCard'; +import { importedToolCardName } from '../utils/importedToolPresentation'; import { getToolItemCardConfig, isToolCapsule } from '../tool-cards/toolCardMetadata'; import { getToolCapsuleSummary, toolCapsuleStateKey } from '../tool-cards/toolCapsuleModel'; import { ToolCapsuleRecord } from '../tool-cards/ToolCapsuleRecord'; @@ -67,7 +69,8 @@ export const FlowToolCard: React.FC = React.memo(({ ? expandedToolCapsules.has(capsuleKey) : localExpanded; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId: toolItem.id, toolName: effectiveToolItem.toolName }); const config = getToolItemCardConfig(effectiveToolItem); - const CardComponent = getToolCardComponent(effectiveToolItem.toolName); + const CardComponent = toolItem.importedSource && !importedToolCardName(toolItem) + ? DefaultToolCard : getToolCardComponent(effectiveToolItem.toolName); const interruptionNote = getToolInterruptionNote(effectiveToolItem, t); const cardHandlesInterruptionNote = ['Task', 'AgentSpawn', 'LaunchReviewAgent'].includes(effectiveToolItem.toolName); const toolCardTestId = @@ -76,11 +79,11 @@ export const FlowToolCard: React.FC = React.memo(({ : effectiveToolItem.toolName === 'WebFetch' ? 'chat-browser-tool-card' : undefined; - const permissionPending = + const permissionPending = !toolItem.importedSource && ( effectiveToolItem.status === 'pending_confirmation' || (effectiveToolItem.requiresConfirmation === true && !effectiveToolItem.userConfirmed && !['completed', 'error', 'cancelled', 'rejected'].includes(effectiveToolItem.status)) || - pendingPermissionToolCallIds?.has(toolItem.toolCall.id) === true; - const capsuleEligible = isToolCapsule(effectiveToolItem.toolName) && !permissionPending; + pendingPermissionToolCallIds?.has(toolItem.toolCall.id) === true); + const capsuleEligible = !toolItem.importedSource && isToolCapsule(effectiveToolItem.toolName) && !permissionPending; const staticAgentWaitCapsule = capsuleEligible && effectiveToolItem.toolName === 'AgentWait'; const handleConfirm = React.useCallback((permissionOptionId?: string, approve?: boolean) => { @@ -152,8 +155,8 @@ export const FlowToolCard: React.FC = React.memo(({ toolItem={effectiveToolItem} config={config} interruptionNote={interruptionNote} - onOpenInEditor={onOpenInEditor} - onOpenInPanel={onOpenInPanel} + onOpenInEditor={toolItem.importedSource ? undefined : onOpenInEditor} + onOpenInPanel={toolItem.importedSource ? undefined : onOpenInPanel} onExpand={handleExpand} sessionId={sessionId} displayContext={displayContext} @@ -161,11 +164,11 @@ export const FlowToolCard: React.FC = React.memo(({ /> - + />} {interruptionNote && !cardHandlesInterruptionNote && (
= React.memo(({ return ( prevProps.toolItem.id === nextProps.toolItem.id && prevProps.toolItem.toolName === nextProps.toolItem.toolName && + prevProps.toolItem.importedSource === nextProps.toolItem.importedSource && prevProps.toolItem.toolCall === nextProps.toolItem.toolCall && prevProps.sessionId === nextProps.sessionId && prevProps.turnId === nextProps.turnId && diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index afe95e2d4f..0f8572f34c 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -858,6 +858,7 @@ describe('UserMessageItem steering tag', () => { }); it.each([ + { binding: { workspaceId: workspaceRecords.local.id, externalHistory: { mode: 'follow' }, config: {} }, reason: 'Source' }, { binding: { workspaceId: workspaceRecords.remote.id, config: {} }, reason: 'Remote' }, { binding: { workspaceId: workspaceRecords.local.id, config: { dispatchJobId: 'job-a100' } }, reason: 'Dispatch' }, { binding: { workspaceId: workspaceRecords.local.id, config: { dispatchTarget: { kind: 'device', deviceId: 'target', workspacePath: '/w', displayName: 'Target' } } }, reason: 'Dispatch' }, @@ -900,10 +901,10 @@ describe('UserMessageItem steering tag', () => { const editButton = getMessageAction('edit'); expect(rollbackButton?.disabled).toBe(true); - expect(rollbackButton?.getAttribute('aria-label')).toContain(`message.rollbackDisabled${reason}`); + expect(rollbackButton?.getAttribute('aria-label')).toContain(reason === 'Source' ? 'externalHistory.forkBeforeEdit' : `message.rollbackDisabled${reason}`); expect(rollbackButton?.hasAttribute('title')).toBe(false); expect(editButton?.disabled).toBe(true); - expect(editButton?.getAttribute('aria-label')).toContain(`message.editDisabled${reason}`); + expect(editButton?.getAttribute('aria-label')).toContain(reason === 'Source' ? 'externalHistory.forkBeforeEdit' : `message.editDisabled${reason}`); expect(editButton?.hasAttribute('title')).toBe(false); }); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 8cec2465bb..41ab39137a 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -239,6 +239,7 @@ export const UserMessageItem = React.memo( : -1; const isDispatchSession = resolveSessionDriverId(resolvedSessionId ?? '', currentSession ?? undefined) === 'dispatch'; const isRemoteSession = !isLocalWorkspaceSession(currentSession ?? undefined, null) || isDispatchSession; + const isFollowedSource = currentSession?.externalHistory?.mode === 'follow'; const isSystemTriggered = Boolean( message?.metadata?.triggerSource && message.metadata.triggerSource !== 'desktop_ui', ); @@ -249,6 +250,7 @@ export const UserMessageItem = React.memo( !!resolvedSessionId && actionTurnIndex >= 0 && !isRemoteSession && + !isFollowedSource && isSessionIdle && !sessionMutation && !isEditSubmitting; @@ -257,6 +259,7 @@ export const UserMessageItem = React.memo( !!resolvedSessionId && actionTurnIndex >= 0 && !isRemoteSession && + !isFollowedSource && !isThreadGoalSystemMessage && !isSystemTriggered && !steeringStatus && !hostQueueStatus; @@ -265,7 +268,9 @@ export const UserMessageItem = React.memo( const canShowRollbackMenuItem = !isFailed && canShowRollbackAction && !steeringStatus && !hostQueueStatus; const canShowActionMenu = canShowEditAction || canShowRollbackMenuItem; const rollbackPending = sessionMutation?.kind === 'rollback' && sessionMutation.targetTurnId === turnId; - const editDisabledReason = isDispatchSession + const editDisabledReason = isFollowedSource + ? t('externalHistory.forkBeforeEdit') + : isDispatchSession ? t('message.editDisabledDispatch') : isRemoteSession ? t('message.editDisabledRemote') @@ -280,6 +285,8 @@ export const UserMessageItem = React.memo( : t('message.cannotEdit'); const rollbackTooltip = canRollback ? t('message.rollbackTo', { index: actionTurnIndex + 1 }) + : isFollowedSource + ? t('externalHistory.forkBeforeEdit') : isDispatchSession ? t('message.rollbackDisabledDispatch') : isRemoteSession diff --git a/src/web-ui/src/flow_chat/grouping/activityClassification.ts b/src/web-ui/src/flow_chat/grouping/activityClassification.ts index 63b87d8694..fe2bb3510b 100644 --- a/src/web-ui/src/flow_chat/grouping/activityClassification.ts +++ b/src/web-ui/src/flow_chat/grouping/activityClassification.ts @@ -1,5 +1,5 @@ import type { FlowToolItem } from '../types/flow-chat'; -import { projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; +import { getEffectiveToolName, projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; const shellTools = new Set(['ExecCommand', 'WriteStdin', 'ExecControl', 'Bash']); @@ -33,13 +33,13 @@ export function awaitsApproval(item: FlowToolItem): boolean { /** A Shell run is an adjacent transcript segment, not a process/session id. */ export function isShellActivity(item: FlowToolItem): boolean { - const effective = projectEffectiveToolItem(item); - return isShellToolName(effective.toolName) + return isShellToolName(getEffectiveToolName(item)) && item.status !== 'rejected' && !awaitsApproval(item); } /** Fold only known low-level observations; unknown or consequential actions stay visible. */ export function isInterfaceObservation(item: FlowToolItem): boolean { + if (item.importedSource) return false; if (['error', 'cancelled', 'rejected'].includes(item.status) || item.isParamsStreaming || (item.status === 'completed' && item.toolResult?.success !== true) || awaitsApproval(item)) return false; @@ -76,10 +76,11 @@ function shellResult(item: FlowToolItem): Record { export function shellActivityStats(items: readonly FlowToolItem[]): ShellActivityStats { const stats = { commands: 0, interactions: 0, controls: 0, running: 0, failed: 0, stopped: 0, nonZero: 0 }; for (const item of items) { - const name = projectEffectiveToolItem(item).toolName; - const result = shellResult(item); + const name = getEffectiveToolName(item); + const effective = projectEffectiveToolItem(item); + const result = shellResult(effective); const completion = record(result.completion); - const failed = item.status === 'error' || item.toolResult?.success === false || result.status === 'session_not_found'; + const failed = effective.status === 'error' || effective.toolResult?.success === false || result.status === 'session_not_found'; const stopped = item.status === 'cancelled' || ['interrupted', 'killed', 'pruned'].includes(String(completion.status ?? '')); if (name === 'ExecCommand' || name === 'Bash') stats.commands++; diff --git a/src/web-ui/src/flow_chat/grouping/fileEdits.ts b/src/web-ui/src/flow_chat/grouping/fileEdits.ts index 1d154df6f8..a1ca46bc1a 100644 --- a/src/web-ui/src/flow_chat/grouping/fileEdits.ts +++ b/src/web-ui/src/flow_chat/grouping/fileEdits.ts @@ -19,6 +19,8 @@ function pathIn(value: Record | undefined): string | undefined export function fileEditTarget(item: FlowItem | undefined): string | undefined { if (item?.type !== 'tool') return undefined; const tool = projectEffectiveToolItem(item as FlowToolItem); + // Native revision groups consult live snapshots; imported edits have only recorded evidence. + if (tool.importedSource) return undefined; if (!['Edit', 'Write'].includes(tool.toolName) || tool.isParamsStreaming || awaitsApproval(tool)) return undefined; const input = object(tool.toolCall?.input); const result = object(tool.toolResult?.result); diff --git a/src/web-ui/src/flow_chat/hooks/useMessageSender.test.tsx b/src/web-ui/src/flow_chat/hooks/useMessageSender.test.tsx index 3046d8d422..2c936f440e 100644 --- a/src/web-ui/src/flow_chat/hooks/useMessageSender.test.tsx +++ b/src/web-ui/src/flow_chat/hooks/useMessageSender.test.tsx @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useMessageSender } from './useMessageSender'; import type { ConversationExcerptContext } from '@/shared/types/context'; import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { FLOWCHAT_MESSAGE_SUBMITTED_EVENT } from '../events/flowchatNavigation'; const mocks = vi.hoisted(() => { const createChatSession = vi.fn(); @@ -176,6 +177,23 @@ describe('useMessageSender', () => { expect(mocks.sendMessage.mock.calls[0][5].sendImmediately).toBe(behavior === 'immediate'); }); + it('announces the new native branch after an external source is forked', async () => { + const submitted = vi.fn(); + window.addEventListener(FLOWCHAT_MESSAGE_SUBMITTED_EVENT, submitted); + mocks.sendMessage.mockImplementationOnce(async (...args: unknown[]) => { + const options = args[5] as { onSessionForked: (sourceId: string, branchId: string) => void }; + options.onSessionForked('created-session', 'native-branch'); + }); + try { + await act(async () => root.render()); + await act(async () => sendFromProbe?.()); + expect(submitted).toHaveBeenCalledOnce(); + expect(submitted.mock.calls[0][0].detail.sessionId).toBe('native-branch'); + } finally { + window.removeEventListener(FLOWCHAT_MESSAGE_SUBMITTED_EVENT, submitted); + } + }); + it.each([true, false])('preserves explicit send intent (%s) without reading the preference', async sendImmediately => { await act(async () => root.render()); await act(async () => sendFromProbe?.()); diff --git a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts index d5f2939c76..808345db6a 100644 --- a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts +++ b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts @@ -276,6 +276,7 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender pendingLargePastes: { ...(options?.composerDraft?.pendingLargePastes ?? {}) }, }, foregroundSubmission: true, + onSessionForked: (_sourceSessionId, branchSessionId) => { sessionId = branchSessionId; }, sendImmediately: sendImmediately && !isAcpFlowSession(targetSession), ...(userMessageMetadata ? { userMessageMetadata } : {}), ...(options?.execution ? { execution: options.execution } : {}), diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 844bfc5f3e..48b85d303d 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -92,6 +92,7 @@ type FlowChatSendMessageOptions = Pick< | 'preserveTurnOnStartError' | 'onSessionConflictRetryStart' | 'onSessionConflictRetrySuccess' + | 'onSessionForked' | 'sessionMutationLeaseId' >; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index 5ffa44f48b..addfa8bbfa 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -40,6 +40,7 @@ const mockDispatchProgress = vi.fn(); const mockDispatchRefresh = vi.fn(); const mockBindSession = vi.fn(); const mockEnsureBackendSession = vi.fn(); +const mockForkChatSession = vi.fn(); const mockNotificationError = vi.fn(); const mockNotificationDismiss = vi.fn(); const mockPendingList = vi.fn((): unknown[] => []); @@ -131,6 +132,7 @@ vi.mock('../../../shared/notification-system', () => ({ })); vi.mock('./SessionModule', () => ({ + forkChatSession: (...args: unknown[]) => mockForkChatSession(...args), ensureBackendSession: (...args: unknown[]) => mockEnsureBackendSession(...args), getModelMaxTokens: vi.fn(async (modelId: string) => modelId === 'primary' ? 32000 : 64000), retryCreateBackendSession: vi.fn(), @@ -259,6 +261,50 @@ describe('MessageModule session writer conflict', () => { expect(session.dialogTurns).toEqual([]); }); + it('forks the viewed source before submitting and leaves source history untouched', async () => { + const { context, session } = conflictContext('external-source'); + Object.assign(session, { + historyState: 'ready', externalHistory: { mode: 'follow', contentVersion: 'newer-source' }, + dialogTurns: [{ id: 'viewed-turn', userMessage: { metadata: { externalContentVersion: 'viewed-version' } } }], + }); + const branch = { ...session, sessionId: 'native-branch', externalHistory: undefined, dialogTurns: [...session.dialogTurns] }; + const sessions = new Map([[session.sessionId, session], [branch.sessionId, branch]]); + context.flowChatStore.getState = () => ({ sessions, activeSessionId: branch.sessionId }); + context.flowChatStore.addDialogTurn.mockImplementation((id: string, turn: any) => sessions.get(id)!.dialogTurns.push(turn)); + mockForkChatSession.mockResolvedValue('native-branch'); + mockEnsureBackendSession.mockResolvedValue(undefined); + mockGetConfigs.mockResolvedValue({}); + const onSessionForked = vi.fn(); + await sendMessage(context, 'Continue here', session.sessionId, undefined, undefined, undefined, { onSessionForked }); + expect(mockForkChatSession).toHaveBeenCalledWith(context, 'external-source', 'viewed-turn'); + expect(onSessionForked).toHaveBeenCalledExactlyOnceWith('external-source', 'native-branch'); + expect(mockStartDialogTurn).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'native-branch', userInput: 'Continue here' })); + expect(session.dialogTurns).toHaveLength(1); + }); + + it('keeps the draft and source untouched when a fork fails', async () => { + const { context, session } = conflictContext('external-source'); + Object.assign(session, { + historyState: 'ready', externalHistory: { mode: 'follow' }, + dialogTurns: [{ id: 'viewed-turn', userMessage: { metadata: { externalContentVersion: 'viewed-version' } } }], + }); + mockForkChatSession.mockRejectedValue(new Error('Source version unavailable')); + await expect(sendMessage(context, 'Keep my draft', session.sessionId)).rejects.toThrow('Source version unavailable'); + expect(mockStartDialogTurn).not.toHaveBeenCalled(); + expect(context.flowChatStore.addDialogTurn).not.toHaveBeenCalled(); + }); + + it('does not fork an archived source before rejecting submission', async () => { + const { context, session } = conflictContext('archived-source'); + Object.assign(session, { + historyState: 'ready', persistedStatus: 'archived', externalHistory: { mode: 'follow' }, + dialogTurns: [{ id: 'viewed-turn', userMessage: { metadata: { externalContentVersion: 'viewed-version' } } }], + }); + await expect(sendMessage(context, 'Keep this draft', session.sessionId)).rejects.toMatchObject({ reason: 'archived' }); + expect(mockForkChatSession).not.toHaveBeenCalled(); + expect(mockStartDialogTurn).not.toHaveBeenCalled(); + }); + it('rechecks capability after backend readiness without creating an optimistic turn', async () => { const { context, session } = conflictContext('capability-race'); const readiness = deferred(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 44cc02847b..c13eec4cc4 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -47,6 +47,8 @@ import { } from '../submittedMessagePresentation'; import { finishSubmittedMessageScrollIntent, peekSubmittedMessageScrollIntent } from '../submittedMessageScrollIntent'; import { FLOWCHAT_MESSAGE_SUBMITTED_EVENT } from '../../events/flowchatNavigation'; +import { forkChatSession } from './SessionModule'; +import { viewedExternalForkTurn } from '../../utils/externalHistory'; export { syncSessionModelSelection } from '../../utils/modelSync'; export { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; @@ -62,6 +64,7 @@ interface SessionConflictRetry { const sessionConflictRetries = new Map(); const latestSendBySession = new Map(); const draftSubmissions = new Set(); +const externalForks = new Map>(); function clearSessionConflictRetry(sendKey: string): void { const current = sessionConflictRetries.get(sendKey); @@ -258,6 +261,27 @@ async function sendMessageInternal( throw new Error(`Session does not exist: ${sessionId}`); } const surfaceScopeAtSend = getActiveSurfaceScope(); + if (session.externalHistory?.mode === 'follow') { + if (surfaceScopeAtSend.surfaceId !== 'local') throw new Error(i18nService.t('flow-chat:externalHistory.unsupportedSurface')); + assertSessionConversationCanSubmit(sessionId, session, i18nService.t('flow-chat:chatInput.targetUnavailable')); + assertSessionSubmissionAllowed(sessionId, options?.sessionMutationLeaseId); + const sourceTurnId = viewedExternalForkTurn(session); + if (!sourceTurnId) throw new Error(i18nService.t('flow-chat:externalHistory.openBeforeContinue')); + const key = surfaceScopeAtSend.key('external-history-fork', surfaceScopeAtSend.epoch, sessionId); + let fork = externalForks.get(key); + if (!fork) { + fork = forkChatSession(context, sessionId, sourceTurnId); + externalForks.set(key, fork); + } + try { + const branchId = await fork; + surfaceScopeAtSend.assertCurrent('continue external history in native branch'); + options?.onSessionForked?.(sessionId, branchId); + return await sendMessageInternal(context, message, branchId, displayMessage, agentType, switchToMode, options); + } finally { + if (externalForks.get(key) === fork) externalForks.delete(key); + } + } if (session.sessionKind === 'subagent' && getSessionConversationCapability(sessionId, session).reason === 'metadata_pending') { const parent = session.parentSessionId diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index ac1c4b2867..a964589ffc 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -28,11 +28,13 @@ import { import type { Session } from '../../types/flow-chat'; import type { ReviewTeamRunManifest } from '@/shared/services/reviewTeamService'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { notificationService } from '../../../shared/notification-system'; const agentApiMocks = vi.hoisted(() => ({ ensureCoordinatorSession: vi.fn(), createSession: vi.fn(), getAvailableModes: vi.fn(), + updateSessionPermissionMode: vi.fn(), })); const configApiMocks = vi.hoisted(() => ({ @@ -582,10 +584,22 @@ describe('createChatSession', () => { }); describe('forkChatSession', () => { + beforeEach(() => { activateSurface(LOCAL_SURFACE_ID); }); afterEach(() => { + activateSurface(LOCAL_SURFACE_ID); vi.clearAllMocks(); }); + it('applies a source composer permission selection only to the created native branch', async () => { + const source = createSession({ sessionId: 'source', externalHistory: { mode: 'follow' }, externalContinuationPermissionMode: 'auto_approve' }); + const { context } = createContext(source); + sessionApiMocks.forkSession.mockResolvedValueOnce({ sessionId: 'branch', sessionName: 'Branch' }); + await forkChatSession(context, 'source', 'viewed-turn'); + expect(agentApiMocks.updateSessionPermissionMode).toHaveBeenCalledExactlyOnceWith({ + sessionId: 'branch', mode: 'auto_approve', workspaceId: 'workspace-1', workspacePath: source.workspacePath, + }); + }); + it('keeps the source SSH identity through fork creation and history restore', async () => { const source = createSession({ sessionId: 'remote-source', @@ -622,6 +636,18 @@ describe('forkChatSession', () => { }); expect(flowChatStore.loadSessionHistory).toHaveBeenCalledWith('remote-fork', { deferFullHistoryUntilActive: true }); }); + + it('does not project a fork into a device selected while the host is responding', async () => { + const source = createSession({ sessionId: 'source' }); + const { context, flowChatStore } = createContext(source); + sessionApiMocks.forkSession.mockImplementationOnce(async () => { + activateSurface('peer-during-fork'); + return { sessionId: 'new-fork', sessionName: 'New fork', agentType: 'Standard' }; + }); + await expect(forkChatSession(context, source.sessionId, 'turn-1')).rejects.toThrow(); + expect(flowChatStore.createSession).not.toHaveBeenCalled(); + expect(flowChatStore.loadSessionHistory).not.toHaveBeenCalled(); + }); }); describe('duplicateChatSession', () => { @@ -777,6 +803,45 @@ describe('SessionModule historical session coordination', () => { vi.clearAllMocks(); }); + it('refreshes a ready source once per open and does not touch, fork or periodically reload it', async () => { + const source = createSession({ isHistorical: false, historyState: 'ready', externalHistory: { mode: 'follow' } }); + const { context, flowChatStore } = createContext(source); + await switchChatSession(context, source.sessionId); + expect(flowChatStore.loadSessionHistory).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(60_000); + expect(flowChatStore.loadSessionHistory).toHaveBeenCalledTimes(1); + expect(persistenceMocks.touchSessionActivity).not.toHaveBeenCalled(); + expect(sessionApiMocks.forkSession).not.toHaveBeenCalled(); + expect(agentApiMocks.ensureCoordinatorSession).not.toHaveBeenCalled(); + await switchChatSession(context, source.sessionId); + expect(flowChatStore.loadSessionHistory).toHaveBeenCalledTimes(2); + }); + + it('does not preload an unopened source even when another history read is pending', async () => { + const source = createSession({ externalHistory: { mode: 'follow' } }); + const { context, flowChatStore } = createContext(source); + context.pendingHistoryLoads.set(pendingHistoryLoadKey('other'), new Promise(() => {})); + preloadHistoricalSessionForOpen(context, source.sessionId); + expect(flowChatStore.loadSessionHistory).not.toHaveBeenCalled(); + }); + + it('retains an unreadable source and exposes its unavailable state after the first open fails', async () => { + const source = createSession({ externalHistory: { mode: 'follow' } }); + const { context, flowChatStore } = createContext(source); + const failure = new Error('Original source is unavailable'); + flowChatStore.loadSessionHistory.mockRejectedValueOnce(failure); + flowChatStore.ensurePersistedSessionMetadata.mockImplementationOnce(async () => { + source.externalHistory!.sourceStatus = 'unavailable'; + return true; + }); + await switchChatSession(context, source.sessionId); + expect(flowChatStore.getState().activeSessionId).toBe(source.sessionId); + expect(flowChatStore.ensurePersistedSessionMetadata).toHaveBeenCalledWith(source.sessionId, 'workspace-1', true); + expect(flowChatStore.getState().sessions.get(source.sessionId)?.externalHistory?.sourceStatus).toBe('unavailable'); + expect(notificationService.warning).toHaveBeenCalledWith('flow-chat:externalHistory.unavailableWithoutCache', { duration: 3000 }); + expect(flowChatStore.loadSessionHistory).toHaveBeenCalledTimes(1); + }); + it('hydrates a metadata-only historical session before switching to avoid an empty loading page', async () => { const load = createDeferred(); const { context, flowChatStore } = createContext(createSession()); @@ -1495,6 +1560,42 @@ describe('SessionModule historical session coordination', () => { expect(persistenceMocks.cleanupSessionBuffers).toHaveBeenCalledWith(context, 'active-1'); }); + it.each(['source', 'branch'])('archives only the selected %s and keeps the independent conversation active', async target => { + const source = createSession({ sessionId: 'source', externalHistory: { mode: 'follow' } }); + const branch = createSession({ sessionId: 'branch', externalBranchOf: { sessionId: 'source', ecosystemId: 'codex', sourceSessionId: 'original', contentVersion: 'viewed' } }); + const other = target === 'source' ? branch : source; + const { context, flowChatStore } = createContext(source, { additionalSessions: [branch], activeSessionId: other.sessionId }); + await archiveChatSession(context, target); + expect(sessionApiMocks.archiveSession).toHaveBeenCalledExactlyOnceWith(target, 'workspace-1'); + expect(flowChatStore.getState().sessions.get(other.sessionId)).toBe(other); + expect(flowChatStore.getState().activeSessionId).toBe(other.sessionId); + expect(flowChatStore.getState().sessions.has(target)).toBe(false); + }); + + it('retains the session and its runtime state when archive fails', async () => { + const session = createSession(); + const { context, flowChatStore, processingManager } = createContext(session, { activeSessionId: session.sessionId }); + sessionApiMocks.archiveSession.mockRejectedValueOnce(new Error('Host offline')); + await expect(archiveChatSession(context, session.sessionId)).rejects.toThrow('Host offline'); + expect(flowChatStore.getState().sessions.get(session.sessionId)).toBe(session); + expect(flowChatStore.getState().activeSessionId).toBe(session.sessionId); + expect(flowChatStore.removeSession).not.toHaveBeenCalled(); + expect(processingManager.clearSessionStatus).not.toHaveBeenCalled(); + }); + + it('abandons archive cleanup after a device change', async () => { + const session = createSession(); + const { context, flowChatStore, processingManager } = createContext(session); + const pending = createDeferred(); + sessionApiMocks.archiveSession.mockReturnValueOnce(pending.promise); + const archive = archiveChatSession(context, session.sessionId); + activateSurface('peer-archive-test'); + pending.resolve(); + await expect(archive).rejects.toMatchObject({ isSurfaceChangedError: true }); + expect(flowChatStore.removeSession).not.toHaveBeenCalled(); + expect(processingManager.clearSessionStatus).not.toHaveBeenCalled(); + }); + it('reuses pending historical hydration before ensuring the backend session', async () => { const pendingHydrate = createDeferred(); const { context, flowChatStore } = createContext(createSession()); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index b474606a05..0760bef641 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -233,6 +233,10 @@ async function hydrateHistoricalSession( deferFullHistoryUntilActive, }); surfaceScope.assertCurrent('finish historical session hydration'); + if (session.externalHistory?.mode === 'follow') { + await context.flowChatStore.ensurePersistedSessionMetadata(sessionId, workspaceId, true); + surfaceScope.assertCurrent('load opened source metadata'); + } })(); context.pendingHistoryLoads.set(pendingKey, loadPromise); @@ -255,13 +259,30 @@ async function hydrateHistoricalSession( if (isSurfaceChangedError(error)) { throw error; } + surfaceScope.assertCurrent('handle historical session failure'); + // A failed first read still records source availability in metadata. Keep + // that state visible without retrying the external history or losing the + // original read failure if this metadata-only request also fails. + if (context.flowChatStore.getState().sessions.get(sessionId)?.externalHistory?.mode === 'follow') { + try { + await context.flowChatStore.ensurePersistedSessionMetadata(sessionId, workspaceId, true); + } catch (metadataError) { + if (isSurfaceChangedError(metadataError)) throw metadataError; + log.debug('Failed to refresh unavailable source metadata', { sessionId, metadataError }); + } + surfaceScope.assertCurrent('show failed source history'); + } recordHistorySessionDiagnosticEvent(sessionId, 'hydrate_request_failed'); startupTrace.markPhase('historical_session_hydrate_request_failed', { durationMs: elapsedMs(traceStartedAt), }); log.error('Failed to load session history', { sessionId, error }); if (notifyOnError) { - notificationService.warning('Failed to load session history, showing empty session', { + const sourceHistory = context.flowChatStore.getState().sessions.get(sessionId)?.externalHistory; + const message = sourceHistory?.sourceStatus === 'unavailable' && !sourceHistory.contentVersion + ? i18nService.t('flow-chat:externalHistory.unavailableWithoutCache') + : 'Failed to load session history, showing empty session'; + notificationService.warning(message, { duration: 3000 }); } @@ -326,6 +347,7 @@ export function preloadHistoricalSessionForOpen( } const session = context.flowChatStore.getState().sessions.get(sessionId); + if (session?.externalHistory?.mode === 'follow') return; if (!shouldHydrateHistoricalSessionBeforeSwitch(session)) { return; } @@ -524,7 +546,8 @@ export async function switchChatSession( const switchRequestId = ++latestSwitchRequestId; const session = context.flowChatStore.getState().sessions.get(sessionId); const isRemoteSession = isRemoteTraceContext(session?.remoteConnectionId, session?.remoteSshHost); - const shouldHydrateBeforeSwitch = shouldHydrateHistoricalSessionBeforeSwitch(session); + const shouldHydrateBeforeSwitch = session?.externalHistory?.mode === 'follow' + || shouldHydrateHistoricalSessionBeforeSwitch(session); const shouldActivateBeforeHydrate = shouldHydrateBeforeSwitch && consumeRecentHistorySessionOpenIntent(sessionId); @@ -539,7 +562,7 @@ export async function switchChatSession( }); const touchActiveSessionInBackground = () => { - if (session?.draft || driverForSession(sessionId, session).id === 'dispatch') { + if (session?.draft || session?.externalHistory?.mode === 'follow' || driverForSession(sessionId, session).id === 'dispatch') { return; } scheduleSessionActivityTouch(surfaceScope, () => { @@ -581,6 +604,7 @@ export async function switchChatSession( // and then activate a metadata-only session with no load left running. // Also upgrades any speculative active-only preload we are reusing. deferFullHistoryUntilActive: shouldActivateBeforeHydrate, + allowNonHistorical: session?.externalHistory?.mode === 'follow', isRetryStillRelevant: () => ( surfaceScope.isCurrent() && switchRequestId === latestSwitchRequestId && isStillRelevant() ), @@ -678,6 +702,7 @@ export async function deleteChatSession( removedActiveSession, }); } catch (error) { + if (isSurfaceChangedError(error)) throw error; log.error('Failed to delete chat session', { sessionId, error }); notificationService.error('Failed to delete session', { duration: 3000 @@ -716,6 +741,7 @@ export async function archiveChatSession( removedActiveSession, }); } catch (error) { + if (isSurfaceChangedError(error)) throw error; log.error('Failed to archive chat session', { sessionId, error }); notificationService.error('Failed to archive session', { duration: 3000 @@ -777,6 +803,7 @@ export async function forkChatSession( sourceSessionId: string, sourceTurnId: string ): Promise { + const surfaceScope = getActiveSurfaceScope(); const sourceSession = context.flowChatStore.getState().sessions.get(sourceSessionId); if (!sourceSession) { throw new Error(`Session does not exist: ${sourceSessionId}`); @@ -798,6 +825,18 @@ export async function forkChatSession( sourceSessionId, sourceTurnId, requireSessionOwningWorkspaceId(sourceSession)); + surfaceScope.assertCurrent('fork session'); + + if (sourceSession.externalHistory?.mode === 'follow' + && sourceSession.externalContinuationPermissionMode !== undefined) { + await agentAPI.updateSessionPermissionMode({ + sessionId: response.sessionId, + mode: sourceSession.externalContinuationPermissionMode, + workspaceId: requireSessionOwningWorkspaceId(sourceSession), + workspacePath: executionWorkspacePath, + }); + surfaceScope.assertCurrent('configure fork permission mode'); + } const currentState = context.flowChatStore.getState(); if (!currentState.sessions.has(response.sessionId)) { @@ -825,6 +864,11 @@ export async function forkChatSession( } await context.flowChatStore.loadSessionHistory(response.sessionId, { deferFullHistoryUntilActive: true }); + surfaceScope.assertCurrent('load forked session'); + if (sourceSession.externalHistory?.mode === 'follow') { + await context.flowChatStore.ensurePersistedSessionMetadata(response.sessionId, requireSessionOwningWorkspaceId(sourceSession), true); + surfaceScope.assertCurrent('load fork lineage'); + } context.flowChatStore.switchSession(response.sessionId); return response.sessionId; diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts index 4a9e0b3ef2..0de85dcd26 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts @@ -76,6 +76,14 @@ describe('openMainSession resource activation', () => { expect(mocks.sceneState.openScene).toHaveBeenCalledWith('session'); }); + it('refreshes a cached source when it is explicitly opened again', async () => { + mocks.flowChatState.activeSessionId = 'source'; + mocks.flowChatState.sessions.set('source', { sessionId: 'source', historyState: 'ready', isHistorical: false, externalHistory: { mode: 'follow' } }); + await openMainSession('source'); + expect(mocks.switchChatSession).toHaveBeenCalledOnce(); + expect(mocks.sceneState.openScene).toHaveBeenCalledWith('session'); + }); + it('waits for successful activation before opening the scene', async () => { mocks.flowChatState.sessions.set('target', { sessionId: 'target' }); mocks.switchChatSession.mockImplementation(async () => { diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.ts b/src/web-ui/src/flow_chat/services/sessionActivation.ts index 86415112c6..e47b522c44 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.ts @@ -67,8 +67,8 @@ export async function activateMainSession(sessionId: string, options?: SessionAc if (isTargetActive()) { const activeSession = flowChatStore.getState().sessions.get(sessionId) ?? null; if ( - activeSession?.isHistorical && - (activeSession.historyState === 'metadata-only' || activeSession.historyState === 'failed') + activeSession?.externalHistory?.mode === 'follow' || (activeSession?.isHistorical && + (activeSession.historyState === 'metadata-only' || activeSession.historyState === 'failed')) ) { await flowChatManager.switchChatSession(sessionId, isCurrent); if (!isTargetActive()) { diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 910e339a79..eb010daab2 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -158,6 +158,7 @@ export const localSessionDriver: SessionDriver = { sessionId: string, removal: SessionCascadeRemoval, ): Promise { + const scope = getActiveSurfaceScope(); const session = context.flowChatStore.getState().sessions.get(sessionId); log.info('Dispatch diagnostic: delete routed to persisted backend session', { sessionId, @@ -167,6 +168,7 @@ export const localSessionDriver: SessionDriver = { sessionId, removal.removedActiveSession ? { nextActiveSessionId: null } : undefined, ); + scope.assertCurrent('clean up deleted session'); removal.removedSessionIds.forEach(id => { context.processingManager.clearSessionStatus(id); @@ -179,6 +181,7 @@ export const localSessionDriver: SessionDriver = { sessionId: string, removal: SessionCascadeRemoval, ): Promise { + const scope = getActiveSurfaceScope(); const session = context.flowChatStore.getState().sessions.get(sessionId); if (!session) { throw new Error(`Session does not exist: ${sessionId}`); @@ -186,6 +189,7 @@ export const localSessionDriver: SessionDriver = { if (session.draft?.phase !== 'editing') { await sessionAPI.archiveSession(sessionId, requireSessionOwningWorkspaceId(session)); + scope.assertCurrent('complete session archive'); } context.flowChatStore.removeSession( diff --git a/src/web-ui/src/flow_chat/session-drivers/types.ts b/src/web-ui/src/flow_chat/session-drivers/types.ts index 4d1bae4d2a..14d22ca167 100644 --- a/src/web-ui/src/flow_chat/session-drivers/types.ts +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -25,6 +25,8 @@ export type { SessionDriverId } from './resolve'; /** Options accepted by FlowChatManager.sendMessage, threaded to the driver. */ export interface SendMessageOptions { + /** A followed source becomes an independent native submission target. */ + onSessionForked?: (sourceSessionId: string, branchSessionId: string) => void; imageContexts?: ImageInputContextData[]; imageDisplayData?: Array<{ id: string; diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 2f78fb49c5..5860f7ed50 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -5,6 +5,7 @@ import { buildModelRoundItemGroups } from '../components/modern/modelRoundItemGr import { getVirtualItemFlowGroups } from '../grouping/selectors'; import { convertDialogTurnToBackendFormat } from '../services/flow-chat-manager/PersistenceModule'; import { sessionActivityStore } from './sessionActivityStore'; +import { sessionComposerStore } from './sessionComposerStore'; import { LOCAL_SURFACE_ID, SurfaceChangedError, @@ -854,8 +855,71 @@ describe('FlowChatStore metadata persistence callbacks', () => { }); describe('FlowChatStore session removal active selection', () => { + beforeEach(() => { + resetStore(); + apiMocks.deleteSession.mockReset().mockResolvedValue(undefined); + stateMachineManagerMock.delete.mockClear(); + sessionComposerStore.setState({ drafts: {} }); + }); afterEach(() => { resetStore(); + sessionComposerStore.setState({ drafts: {} }); + }); + + it('keeps a failed deletion selected with its history, state machine and draft, then permits retry', async () => { + const session = createSession(); + flowChatStore.setState(() => ({ sessions: new Map([[session.sessionId, session]]), activeSessionId: session.sessionId })); + sessionComposerStore.getState().setValue(session.sessionId, 'Unsent message'); + apiMocks.deleteSession.mockRejectedValueOnce(new Error('Host is offline')); + await expect(flowChatStore.deleteSession(session.sessionId, { nextActiveSessionId: null })) + .rejects.toThrow('Host is offline'); + expect(flowChatStore.getState().sessions.get(session.sessionId)).toBe(session); + expect(flowChatStore.getState().activeSessionId).toBe(session.sessionId); + expect(sessionComposerStore.getState().getDraft(session.sessionId).value).toBe('Unsent message'); + expect(stateMachineManagerMock.delete).not.toHaveBeenCalled(); + await flowChatStore.deleteSession(session.sessionId, { nextActiveSessionId: null }); + expect(flowChatStore.getState().sessions.has(session.sessionId)).toBe(false); + expect(flowChatStore.getState().activeSessionId).toBeNull(); + expect(sessionComposerStore.getState().getDraft(session.sessionId).value).toBe(''); + }); + + it('does not delete an owner after a child deletion fails', async () => { + const parent = createSession({ sessionId: 'parent' }); + const child = createSession({ sessionId: 'child', parentSessionId: 'parent' }); + flowChatStore.setState(() => ({ sessions: new Map([[parent.sessionId, parent], [child.sessionId, child]]), activeSessionId: 'parent' })); + apiMocks.deleteSession.mockRejectedValueOnce(new Error('Child is still running')); + await expect(flowChatStore.deleteSession('parent')).rejects.toThrow('Child is still running'); + expect(apiMocks.deleteSession).toHaveBeenCalledExactlyOnceWith('child', child.workspaceId); + expect([...flowChatStore.getState().sessions.keys()]).toEqual(['parent', 'child']); + expect(stateMachineManagerMock.delete).not.toHaveBeenCalled(); + }); + + it.each(['source', 'branch'])('deletes only the selected %s and retains the independent conversation', async target => { + const source = createSession({ sessionId: 'source', externalHistory: { mode: 'follow' } }); + const branch = createSession({ sessionId: 'branch', externalBranchOf: { sessionId: 'source', ecosystemId: 'codex', sourceSessionId: 'original', contentVersion: 'viewed' } }); + const other = target === 'source' ? branch : source; + flowChatStore.setState(() => ({ sessions: new Map([[source.sessionId, source], [branch.sessionId, branch]]), activeSessionId: other.sessionId })); + sessionComposerStore.getState().setValue(other.sessionId, 'Keep this draft'); + await flowChatStore.deleteSession(target); + expect(apiMocks.deleteSession).toHaveBeenCalledExactlyOnceWith(target, source.workspaceId); + expect(flowChatStore.getState().sessions.get(other.sessionId)).toBe(other); + expect(flowChatStore.getState().activeSessionId).toBe(other.sessionId); + expect(sessionComposerStore.getState().getDraft(other.sessionId).value).toBe('Keep this draft'); + }); + + it('does not remove a same-id session on another device after deletion completes', async () => { + const session = createSession(); + flowChatStore.setState(() => ({ sessions: new Map([[session.sessionId, session]]), activeSessionId: session.sessionId })); + const result = createDeferred(); + apiMocks.deleteSession.mockReturnValueOnce(result.promise); + const deletion = flowChatStore.deleteSession(session.sessionId); + activateSurface('peer-delete-test'); + const peerSession = createSession({ title: 'Peer session' }); + flowChatStore.setState(() => ({ sessions: new Map([[peerSession.sessionId, peerSession]]), activeSessionId: peerSession.sessionId })); + result.resolve(); + await expect(deletion).rejects.toBeInstanceOf(SurfaceChangedError); + expect(flowChatStore.getState().sessions.get(peerSession.sessionId)).toBe(peerSession); + expect(stateMachineManagerMock.delete).not.toHaveBeenCalled(); }); it('can clear the active session atomically while keeping other sessions', () => { @@ -1232,6 +1296,33 @@ describe('FlowChatStore round attempts', () => { expect(JSON.stringify(payload)).toBe(serialized); }); + it.each(['codex', 'zcode'] as const)('derives %s provenance per Turn and round-trips legacy imported tool records', source => { + const record = { + turnId: 'imported-turn', sessionId: 'session-1', status: 'completed', timestamp: 1000, + userMessage: { id: 'user', content: 'Imported question', timestamp: 1000, metadata: { importedFrom: source } }, + modelRounds: [{ + id: 'round', roundIndex: 0, status: 'completed', timestamp: 1000, + textItems: [], thinkingItems: [], toolItems: [{ + id: 'tool', toolName: source === 'codex' ? 'exec' : 'Bash', status: 'completed', + toolCall: { id: 'tool', input: source === 'codex' ? 'text(1)' : { command: 'pwd' } }, + toolResult: { success: true, result: [{ type: 'input_text', text: 'original output' }] }, + }], + }], + }; + const native = { ...record, turnId: 'native-turn', userMessage: { ...record.userMessage, metadata: {} } }; + const [restored, continued] = (flowChatStore as any).convertToDialogTurns([record, native]); + expect(restored.modelRounds[0].items[0].importedSource).toBe(source); + expect(continued.modelRounds[0].items[0].importedSource).toBeUndefined(); + const saved = convertDialogTurnToBackendFormat(restored, 0); + expect(saved.userMessage.metadata.importedFrom).toBe(source); + expect(saved.modelRounds[0].toolItems[0].toolName).toBe(record.modelRounds[0].toolItems[0].toolName); + expect(saved.modelRounds[0].toolItems[0].toolCall).toEqual(record.modelRounds[0].toolItems[0].toolCall); + expect(saved.modelRounds[0].toolItems[0].toolResult).toEqual(record.modelRounds[0].toolItems[0].toolResult); + expect(saved.modelRounds[0].toolItems[0]).not.toHaveProperty('importedSource'); + const [reloaded] = (flowChatStore as any).convertToDialogTurns([JSON.parse(JSON.stringify(saved))]); + expect(reloaded.modelRounds[0].items[0].importedSource).toBe(source); + }); + it('supersedes active items from an older attempt when a newer attempt starts in the same round', () => { const session = createSession({ dialogTurns: [{ @@ -1965,6 +2056,40 @@ describe('FlowChatStore historical session hydration state', () => { }); }); + it.each(['codex', 'zcode'])('preserves %s provenance when loading metadata without restoring a transcript', async importedFrom => { + apiMocks.listSessions.mockResolvedValueOnce([{ + sessionId: 'imported-history', sessionName: 'Imported', agentType: 'primary', modelName: 'primary', + createdAt: 10, lastActiveAt: 20, customMetadata: { importedFrom, sourceSessionId: 'external-id' }, + }]); + await flowChatStore.initializeFromDisk(fixtureWorkspaceId('D:/workspace/OpenBitFun'), undefined); + expect(flowChatStore.getState().sessions.get('imported-history')).toMatchObject({ + importedSource: importedFrom, historyState: 'metadata-only', dialogTurns: [], + }); + expect(apiMocks.restoreSessionView).not.toHaveBeenCalled(); + }); + + it('refreshes followed-source names, activity and versions without replacing viewed history', async () => { + const workspaceId = fixtureWorkspaceId('D:/workspace/OpenBitFun'); + const sourceMetadata = { + sessionId: 'followed-source', sessionName: 'Original source name', workspaceId, + agentType: 'primary', modelName: 'primary', createdAt: 10, lastActiveAt: 20, + customMetadata: { importedFrom: 'codex', externalHistoryMode: 'follow', externalHistoryPending: true, externalContentVersion: 'viewed' }, + }; + apiMocks.listSessions.mockResolvedValueOnce([sourceMetadata]); + await flowChatStore.initializeFromDisk(workspaceId, undefined); + const viewedTurns = flowChatStore.getState().sessions.get('followed-source')!.dialogTurns; + apiMocks.loadSessionMetadata.mockResolvedValueOnce({ + ...sourceMetadata, sessionName: 'Renamed on the source', lastActiveAt: 42_000, + customMetadata: { ...sourceMetadata.customMetadata, externalContentVersion: 'new' }, + }); + await flowChatStore.ensurePersistedSessionMetadata('followed-source', workspaceId, true); + expect(flowChatStore.getState().sessions.get('followed-source')).toMatchObject({ + title: 'Renamed on the source', lastActiveAt: 42_000, externalHistory: { mode: 'follow', contentVersion: 'new' }, + }); + expect(flowChatStore.getState().sessions.get('followed-source')!.dialogTurns).toBe(viewedTurns); + expect(apiMocks.restoreSessionView).not.toHaveBeenCalled(); + }); + it('lists pre-ID worktree sessions under their project without borrowing its execution identity', async () => { const legacyWorktreeMetadata = (sessionId: string, projectPath: string) => ({ sessionId, @@ -3787,6 +3912,48 @@ describe('FlowChatStore historical session hydration state', () => { expect(flowChatStore.getState().sessions.get('history-1')?.remoteSshHost).toBeUndefined(); }); + it('refreshes recent metadata after a discovery setting changes while keeping ordinary page deduplication', async () => { + const workspaceId = fixtureWorkspaceId('D:/workspace/OpenBitFun'); + const savedPage = { + sessions: [], totalTopLevelCount: 1, loadedTopLevelCount: 1, hasMore: false, + discoveryPending: false, + }; + const discoveringPage = { ...savedPage, discoveryPending: true }; + apiMocks.listSessionsPage.mockResolvedValueOnce(savedPage).mockResolvedValueOnce(discoveringPage); + + await flowChatStore.loadSessionMetadataPage(workspaceId, 5, undefined, 'nav_initial'); + await flowChatStore.loadSessionMetadataPage(workspaceId, 5, undefined, 'nav_initial'); + expect(apiMocks.listSessionsPage).toHaveBeenCalledTimes(1); + const refreshed = await flowChatStore.loadSessionMetadataPage( + workspaceId, 5, undefined, 'sessions_nav_discovery_settings', { fresh: true }, + ); + expect(refreshed.discoveryPending).toBe(true); + expect(apiMocks.listSessionsPage).toHaveBeenCalledTimes(2); + }); + + it('bypasses an older in-flight metadata read and retains the fresh page when that read completes late', async () => { + const workspaceId = fixtureWorkspaceId('D:/workspace/OpenBitFun'); + const savedPage = { + sessions: [], totalTopLevelCount: 1, loadedTopLevelCount: 1, hasMore: false, + discoveryPending: false, + }; + const previous = createDeferred(); + const discoveringPage = { ...savedPage, discoveryPending: true }; + apiMocks.listSessionsPage.mockReturnValueOnce(previous.promise).mockResolvedValueOnce(discoveringPage); + const firstLoad = flowChatStore.loadSessionMetadataPage(workspaceId, 5, undefined, 'nav_initial'); + await vi.waitFor(() => expect(apiMocks.listSessionsPage).toHaveBeenCalledTimes(1)); + + const refreshed = await flowChatStore.loadSessionMetadataPage( + workspaceId, 5, undefined, 'sessions_nav_discovery_settings', { fresh: true }, + ); + expect(refreshed.discoveryPending).toBe(true); + previous.resolve(savedPage); + await firstLoad; + const cached = await flowChatStore.loadSessionMetadataPage(workspaceId, 5, undefined, 'sessions_nav_discovery'); + expect(cached.discoveryPending).toBe(true); + expect(apiMocks.listSessionsPage).toHaveBeenCalledTimes(2); + }); + it('starts model config lookup while a paged metadata request is in flight', async () => { const events: string[] = []; const page = createDeferred<{ @@ -7884,6 +8051,34 @@ describe('FlowChatStore device surfaces', () => { }); }); + it('retains the viewed history when a source refresh loses permission to apply', async () => { + seedSession('source-refresh'); + stateMachineManagerMock.reset.mockClear(); + const original = flowChatStore.getState().sessions.get('source-refresh'); + let mayApply = true; + apiMocks.restoreSessionView.mockImplementationOnce(async () => { + mayApply = false; + return { session: restoredSession('source-refresh'), turns: [restoredTurn('source-refresh')], contextRestoreState: 'ready' }; + }); + await flowChatStore.loadSessionHistory('source-refresh', { shouldApply: () => mayApply }); + expect(flowChatStore.getState().sessions.get('source-refresh')).toBe(original); + expect(stateMachineManagerMock.reset).not.toHaveBeenCalled(); + }); + + it('rejects a followed peer source without falling back to controller history', async () => { + activateSurface(PEER_SURFACE_ID); + seedSession('peer-source'); + const previousRelayReads = apiMocks.subscribeRelaySession.mock.calls.length; + flowChatStore.setState(prev => ({ ...prev, sessions: new Map(prev.sessions).set('peer-source', { + ...prev.sessions.get('peer-source')!, externalHistory: { mode: 'follow' }, + }) })); + await expect(flowChatStore.loadSessionHistory('peer-source')).rejects.toThrow(); + expect(flowChatStore.getState().sessions.get('peer-source')?.historyState).toBe('failed'); + expect(apiMocks.restoreSessionView).not.toHaveBeenCalled(); + expect(apiMocks.subscribeRelaySession).toHaveBeenCalledTimes(previousRelayReads); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + }); + it('never projects a local interaction snapshot into the peer selected mid-refresh', async () => { seedSession('shared-session'); const restore = createDeferred(); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 5c001ce9e5..fcab35017c 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1,6 +1,7 @@ import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { resolveLegacySessionWorkspace } from '@/infrastructure/api/service-api/legacyWorkspaceCompatibility'; import { projectUserQuestionTiming } from '../utils/userQuestionTiming'; +import { externalHistoryFromMetadata } from '../utils/externalHistory'; /** * Flow Chat global state store * Prevents state loss when components remount @@ -5364,53 +5365,36 @@ export class FlowChatStore { } public async deleteSession(sessionId: string, options?: RemoveSessionOptions): Promise { + const scope = getActiveSurfaceScope(); const sessionIdsToDelete = this.getCascadeSessionIds(sessionId); if (sessionIdsToDelete.length === 0) { return; } - if (options) { - this.pendingRemoveSessionOptions.set(sessionId, options); - } - - const { stateMachineManager } = await import('../state-machine'); - sessionIdsToDelete.forEach(id => { - stateMachineManager.delete(id); - }); - + const pendingOptions = this.pendingRemoveSessionOptions; + if (options) pendingOptions.set(sessionId, options); try { - const { agentAPI } = await import('@/infrastructure/api/service-api/AgentAPI'); - const deleteResults = await Promise.allSettled( - sessionIdsToDelete.map(async id => { - const sess = this.state.sessions.get(id); - if (sess?.draft?.phase === 'editing') return; - const workspacePath = sess ? sessionProjectWorkspacePath(sess) : undefined; - if (!workspacePath) { + // Children are ordered before their owner. A failed child must keep its + // parent, and a rejected mutation must keep the session and its drafts. + for (const id of sessionIdsToDelete) { + scope.assertCurrent('delete session'); + const sess = this.state.sessions.get(id); + if (sess?.draft?.phase !== 'editing') { + if (!sess || !sessionProjectWorkspacePath(sess)) { throw new Error(`Workspace path not found for session ${id}`); } - - await agentAPI.deleteSession( - id, - this.sessionHistoryWorkspaceId(id) - ); - }) - ); - - deleteResults.forEach((result, index) => { - if (result.status === 'rejected') { - log.error('Failed to delete session on backend', { - sessionId: sessionIdsToDelete[index], - error: result.reason, - }); + await agentAPI.deleteSession(id, this.sessionHistoryWorkspaceId(id)); + scope.assertCurrent('complete session deletion'); } - }); - } catch (error) { - log.error('Failed to delete session on backend', { sessionId, error }); + stateMachineManager.delete(id); + const removedIds = this.removeSession(id, id === sessionId ? options : undefined); + // A host deletion event may already have removed the presentation. + const confirmedIds = [...new Set([id, ...removedIds])]; + sessionComposerStore.getState().removeDrafts(confirmedIds); + askUserQuestionDraftStore.getState().removeSessionDrafts(confirmedIds); + } + } finally { + pendingOptions.delete(sessionId); } - - const removedSessionIds = this.removeSession(sessionId, options); - sessionComposerStore.getState().removeDrafts(removedSessionIds); - askUserQuestionDraftStore.getState().removeSessionDrafts(removedSessionIds); - this.pendingRemoveSessionOptions.delete(sessionId); } public removeSession(sessionId: string, options?: RemoveSessionOptions): string[] { @@ -7346,7 +7330,9 @@ export class FlowChatStore { scope.assertCurrent('processPersistedSessionMetadata'); const existingSession = this.state.sessions.get(metadata.sessionId); if ((existingSession?.workspaceId || existingSession?.config.workspaceId) - && !(existingSession?.sessionKind === 'subagent' && existingSession.continuationPolicy === undefined)) { + && !(existingSession?.sessionKind === 'subagent' && existingSession.continuationPolicy === undefined) + && !existingSession?.externalHistory && !existingSession?.externalBranchOf + && !metadata.customMetadata?.externalHistoryMode && !metadata.customMetadata?.externalBranchOf) { return; } if (!includeArchived && metadata.status === 'archived') { @@ -7399,15 +7385,22 @@ export class FlowChatStore { const existing = prev.sessions.get(metadata.sessionId); if (existing) { const continuationPolicy = existing.continuationPolicy ?? relationship.continuationPolicy; + const importedSource = metadata.customMetadata?.importedFrom ?? existing.importedSource; + const externalHistory = externalHistoryFromMetadata(metadata.customMetadata); + const externalBranchOf = metadata.customMetadata?.externalBranchOf; + const followedFields = externalHistory ? { ...titleState, lastActiveAt: metadata.lastActiveAt } : {}; if (existing.workspaceId || existing.config.workspaceId || !workspaceId) { - if (continuationPolicy === existing.continuationPolicy) return prev; + if (continuationPolicy === existing.continuationPolicy && importedSource === existing.importedSource + && (!externalHistory || (existing.title === titleState.title && existing.lastActiveAt === metadata.lastActiveAt)) + && JSON.stringify(externalHistory) === JSON.stringify(existing.externalHistory) + && JSON.stringify(externalBranchOf) === JSON.stringify(existing.externalBranchOf)) return prev; const sessions = new Map(prev.sessions); - sessions.set(metadata.sessionId, { ...existing, continuationPolicy }); + sessions.set(metadata.sessionId, { ...existing, ...followedFields, continuationPolicy, importedSource, externalHistory, externalBranchOf }); return { ...prev, sessions }; } const sessions = new Map(prev.sessions); sessions.set(metadata.sessionId, { - ...existing, workspaceId, projectWorkspaceId, continuationPolicy, + ...existing, ...followedFields, workspaceId, projectWorkspaceId, continuationPolicy, importedSource, externalHistory, externalBranchOf, workspacePath: metadata.workspacePath || workspacePath, projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, ...remoteScope, @@ -7438,6 +7431,9 @@ export class FlowChatStore { status: 'idle', persistedStatus: metadata.status, persistedTurnCount: metadata.turnCount, + importedSource: metadata.customMetadata?.importedFrom, + externalHistory: externalHistoryFromMetadata(metadata.customMetadata), + externalBranchOf: metadata.customMetadata?.externalBranchOf, config: { agentType: restoredAgentType, modelName: metadata.modelName, @@ -7528,10 +7524,11 @@ export class FlowChatStore { public async ensurePersistedSessionMetadata( sessionId: string, workspaceId: string, + force = false, ): Promise { const scope = getActiveSurfaceScope(); const existing = this.state.sessions.get(sessionId); - if ((existing?.workspaceId || existing?.config.workspaceId) + if (!force && (existing?.workspaceId || existing?.config.workspaceId) && !(existing?.sessionKind === 'subagent' && existing.continuationPolicy === undefined)) { return true; } @@ -7558,7 +7555,8 @@ export class FlowChatStore { workspaceId: string, limit: number, cursor?: string, - traceSource = 'unknown' + traceSource = 'unknown', + options?: { fresh?: boolean }, ): Promise { const { remoteConnectionId, remoteSshHost } = this.workspaceForId(workspaceId); const requestKey = this.getMetadataPageRequestKey( @@ -7566,7 +7564,7 @@ export class FlowChatStore { ); const existingRequest = this.metadataPageRequests.get(requestKey); const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); - if (existingRequest) { + if (existingRequest && !options?.fresh) { const completedAtMs = existingRequest.completedAtMs; const isRecentCompletedRequest = completedAtMs !== undefined && @@ -7582,10 +7580,10 @@ export class FlowChatStore { }); return existingRequest.promise; } + } - if (existingRequest.cleanupTimer) { - clearTimeout(existingRequest.cleanupTimer); - } + if (existingRequest) { + if (existingRequest.cleanupTimer) clearTimeout(existingRequest.cleanupTimer); this.metadataPageRequests.delete(requestKey); } @@ -7886,6 +7884,9 @@ export class FlowChatStore { status: 'idle', persistedStatus: metadata.status, persistedTurnCount: metadata.turnCount, + importedSource: metadata.customMetadata?.importedFrom, + externalHistory: externalHistoryFromMetadata(metadata.customMetadata), + externalBranchOf: metadata.customMetadata?.externalBranchOf, config: { agentType: restoredAgentType, modelName: metadata.modelName, @@ -8485,6 +8486,8 @@ export class FlowChatStore { limit?: number; includeInternal?: boolean; deferFullHistoryUntilActive?: boolean; + /** A background source refresh must not replace a view after drafting starts. */ + shouldApply?: () => boolean; } ): Promise { const traceStartedAt = nowMs(); @@ -8548,6 +8551,12 @@ export class FlowChatStore { return; } if (scope.surfaceId !== 'local') { + if (initialSession?.externalHistory?.mode === 'follow') { + // Relay history has its own cursor/version contract. Until it can pin + // immutable source snapshots, do not silently project a stale source. + this.setSessionHistoryState(sessionId, 'failed'); + throw new Error(i18nService.t('flow-chat:externalHistory.unsupportedSurface')); + } this.setSessionHistoryState(sessionId, 'hydrating'); await this.loadRelaySessionHistory(sessionId); scope.assertCurrent('load relay session history'); @@ -8561,6 +8570,7 @@ export class FlowChatStore { initialSession.historyState === 'metadata-only'; let hydratingStateNotified = false; const notifyHydratingState = (): void => { + if (options?.shouldApply) return; if (suppressInitialHydratingState) { return; } @@ -8753,6 +8763,7 @@ export class FlowChatStore { // machine they are about to drive is shared, so a stale hydrate must stop // here rather than commit into whichever surface is rendered now. scope.assertCurrent('loadSessionHistory'); + if (options?.shouldApply && !options.shouldApply()) return; // A local restore may have started just before the observer bound this // session. Re-check ownership after every restore await and before any // commit or state-machine mutation so that late empty history cannot @@ -9140,6 +9151,8 @@ export class FlowChatStore { id: tool.id, type: 'tool' as const, toolName: tool.toolName, + importedSource: metadata?.importedFrom === 'codex' || metadata?.importedFrom === 'zcode' + ? metadata.importedFrom : undefined, toolCall: tool.toolCall, interruptionReason: normalizePersistedToolInterruptionReason( tool.interruptionReason, diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.test.tsx index 9791815c4e..1a739ab470 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.test.tsx @@ -6,6 +6,7 @@ import { JSDOM } from 'jsdom'; import { DefaultToolCard } from './DefaultToolCard'; import { getToolCardConfig } from './toolCardMetadata'; +import { projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -171,4 +172,15 @@ describe('DefaultToolCard', () => { expect(toJSON).toHaveBeenCalled(); expect(container.textContent).toContain('large fetched page content'); }); + + it('retains recorded output for an imported failed tool in the common card', () => { + const toolItem: FlowToolItem = { ...completedWebFetchItem('Partial recorded output'), toolName: 'unknown', importedSource: 'codex', status: 'error', + toolResult: { success: false, result: 'Partial recorded output', error: 'Recorded failure' } }; + act(() => root.render()); + const surface = container.querySelector('[data-openbitfun-part="surface"]'); + act(() => surface!.click()); + expect(container.querySelector('[data-openbitfun-adapter="default-tool-card"]')).not.toBeNull(); + expect(container.textContent).toContain('Partial recorded output'); + expect(container.textContent).toContain('Recorded failure'); + }); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx index b8e6009775..41ee170661 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx @@ -102,7 +102,7 @@ export const DefaultToolCard: React.FC = ({ toolName: config.toolName, }); - const filteredInput = useMemo(() => sanitizeToolInput(toolCall?.input), [toolCall?.input]); + const filteredInput = useMemo(() => toolItem.importedSource ? toolCall?.input : sanitizeToolInput(toolCall?.input), [toolCall?.input, toolItem.importedSource]); const hasInput = useMemo(() => hasVisibleValue(filteredInput), [filteredInput]); const hasResult = toolResult !== undefined && toolResult !== null && config.resultDisplayType !== 'hidden'; const errorMessage = status === 'error' ? toolResult?.error || t('toolCards.default.failed') : null; diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx index 5afe76daf2..43cbd49812 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ dispatchSession: false, openDispatchFile: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), snapshotsAvailable: true, + snapshotSessionIds: [] as Array, emitSnapshotEvent: vi.fn(), getOperationSummary: vi.fn<(sessionId: string, operationId: string) => Promise<{ linesAdded: number; linesRemoved: number; @@ -83,13 +84,16 @@ vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ })); vi.mock('../../tools/snapshot_system/hooks/useSnapshotState', () => ({ - useSnapshotState: () => ({ + useSnapshotState: (sessionId?: string) => { + mocks.snapshotSessionIds.push(sessionId); + return ({ surfaceEpoch: getActiveSurfaceScope().epoch, snapshotsAvailable: mocks.snapshotsAvailable, files: [], error: null, clearError: vi.fn(), - }), + }); + }, })); vi.mock('../../tools/snapshot_system/core/SnapshotEventBus', () => ({ @@ -165,6 +169,7 @@ describe('FileOperationToolCard', () => { mocks.currentWorkspace = undefined; mocks.snapshotsAvailable = true; + mocks.snapshotSessionIds = []; mocks.emitSnapshotEvent.mockClear(); mocks.getOperationSummary.mockReset().mockResolvedValue(null); mocks.createDiffEditorTab.mockReset(); @@ -189,6 +194,30 @@ describe('FileOperationToolCard', () => { } as FlowToolItem)); } const groupHeader = () => container.querySelector('[data-openbitfun-component="file-edit-group"][data-openbitfun-part="header"]'); + + it.each(['local', 'peer-b'])('renders imported file changes without live file or snapshot actions on %s', async surface => { + activateSurface(surface); + const toolItem: FlowToolItem = { ...groupItems(true)[0], importedSource: 'zcode', endTime: 1 }; + await act(async () => root.render()); + await act(async () => root.render()); + expect(container.querySelector('[data-testid="chat-file-change-card"]')).not.toBeNull(); + expect(container.querySelector('[data-openbitfun-change="added"]')?.textContent).toBe('+1'); + expect(container.querySelector('[data-testid="chat-file-change-open-file"]')).toBeNull(); + expect(mocks.snapshotSessionIds.every(id => id === undefined)).toBe(true); + expect(mocks.getOperationSummary).not.toHaveBeenCalled(); + expect(mocks.getOperationDiff).not.toHaveBeenCalled(); + expect(mocks.emitSnapshotEvent).not.toHaveBeenCalled(); + expect(mocks.openFile).not.toHaveBeenCalled(); + }); + + it('keeps imported plan-file writes on the ordinary file card without native plan actions', async () => { + const toolItem: FlowToolItem = { ...groupItems(true)[0], toolName: 'Write', importedSource: 'zcode', + toolCall: { id: 'foreign-plan', input: { file_path: '/remote/task.plan.md', content: '# Recorded plan' } } }; + await act(async () => root.render()); + expect(container.querySelector('[data-testid="chat-file-change-card"]')).not.toBeNull(); + expect(mocks.writePlanDisplayProps).toHaveLength(0); + expect(mocks.getOperationSummary).not.toHaveBeenCalled(); + }); async function renderFileGroup(items: FlowToolItem[], expanded = false, sessionId = 'session') { await act(async () => root.render( = ({ const toolId = toolItem.id ?? toolCall?.id; const status = getToolCardStatus(toolItem); const isFailed = status === 'error'; + const imported = Boolean(toolItem.importedSource); // Streaming stays in the summary until the reader asks for the live preview. const [isContentExpanded, setIsContentExpanded] = useToolCardDisclosure('isContentExpanded'); @@ -162,14 +163,15 @@ const GenericFileOperationToolCard: React.FC = ({ const { surfaceEpoch, - snapshotsAvailable, + snapshotsAvailable: nativeSnapshotsAvailable, error, clearError - } = useSnapshotState(sessionId); + } = useSnapshotState(imported ? undefined : sessionId); + const snapshotsAvailable = !imported && nativeSnapshotsAvailable; // A recorded operation is viewable even when older Session history has no // complete snapshot coverage. Absence preserves compatibility with old hosts. const isDispatchSession = hasSessionFileProvider(sessionId); - const operationSnapshotAvailable = !isDispatchSession + const operationSnapshotAvailable = !imported && !isDispatchSession && (snapshotsAvailable || toolResult?.result?.snapshot_recorded === true); const eventBus = SnapshotEventBus.getInstance(); const { workspace: currentWorkspace } = useOptionalCurrentWorkspace(); @@ -478,6 +480,7 @@ const GenericFileOperationToolCard: React.FC = ({ }, [snapshotsAvailable, operationSnapshotAvailable, sessionId, currentFilePath, openFilePath, toolCall?.id, fileName, toolItem.toolName]); const canOpenFullCode = + !imported && !isFailed && toolItem.toolName !== 'Delete' && status === 'completed' && @@ -507,7 +510,7 @@ const GenericFileOperationToolCard: React.FC = ({ ]); const handleCodeLineClick = useCallback(async (lineNumber: number, filePath?: string) => { - if (!filePath) return; + if (imported || !filePath) return; if (openFileThroughSession(sessionId, filePath, fileName, { start: lineNumber })) { return; } @@ -517,7 +520,7 @@ const GenericFileOperationToolCard: React.FC = ({ } catch (error) { log.error('Failed to jump to line', { filePath, lineNumber, error }); } - }, [sessionId, fileName]); + }, [imported, sessionId, fileName]); const renderExpandedContent = () => { if (isFailed) return null; @@ -540,7 +543,7 @@ const GenericFileOperationToolCard: React.FC = ({ showLineNumbers={false} maxHeight={previewMaxHeight} autoScrollToBottom={false} - onLineClick={handleCodeLineClick} + onLineClick={imported ? undefined : handleCodeLineClick} />
); @@ -579,7 +582,7 @@ const GenericFileOperationToolCard: React.FC = ({ showLineNumbers={false} maxHeight={previewMaxHeight} autoScrollToBottom={false} - onLineClick={handleCodeLineClick} + onLineClick={imported ? undefined : handleCodeLineClick} />
); @@ -706,6 +709,7 @@ export const FileOperationToolCard: React.FC = (prop const targetPath = fileOperationPath(props.toolItem); const planFilePath = resolveOpenFilePath(targetPath, currentWorkspace?.rootPath); const isPlanWrite = props.toolItem.toolName === 'Write' + && !props.toolItem.importedSource && !isFailedFileOperation(props.toolItem) && planFilePath.replace(/\\/g, '/').toLowerCase().endsWith('.plan.md'); diff --git a/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx index 6d5894b1ff..4de89706fb 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx @@ -89,7 +89,8 @@ export const GlobSearchDisplay: React.FC = ({ const pattern = getSearchPattern(); const searchPath = getSearchPath(); - const hasDetails = status === 'completed' && files.length > 0; + const textOutput = files.length === 0 && typeof toolResult?.result?.content === 'string' ? toolResult.result.content : ''; + const hasDetails = (status === 'completed' || Boolean(toolItem.importedSource)) && (files.length > 0 || textOutput.length > 0); const hasResultData = Array.isArray(toolResult?.result) || Array.isArray(toolResult?.result?.files) || Array.isArray(toolResult?.result?.matches); @@ -118,12 +119,13 @@ export const GlobSearchDisplay: React.FC = ({ details={hasDetails ? [ { label: `${t('toolCards.globSearch.labelPattern')}:`, value: pattern }, { label: `${t('toolCards.globSearch.labelPath')}:`, value: searchPath }, - { + ...(hasResultData ? [{ label: `${t('toolCards.globSearch.labelStats')}:`, value: stats.directories > 0 ? t('toolCards.globSearch.filesAndDirs', { files: stats.files, directories: stats.directories }) : t('toolCards.globSearch.filesCount', { count: stats.files }), - }, + }] : []), + ...(textOutput ? [{ label: t('toolCards.common.executionResult'), value: textOutput }] : []), ] : undefined} results={hasDetails ? files.slice(0, 50).map((file: any, index: number) => { const fileName = typeof file === 'string' ? file : (file.name || file.path || ''); diff --git a/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx index 153d8ad28f..b08a1f6456 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx @@ -65,7 +65,8 @@ export const GrepSearchDisplay: React.FC = ({ const pattern = getSearchPattern(); const searchPath = getSearchPath(); - const hasDetails = status === 'completed' && stats.matches > 0; + const hasDetails = (status === 'completed' || Boolean(toolItem.importedSource)) + && (stats.matches > 0 || typeof toolResult?.result?.result === 'string' && toolResult.result.result.length > 0); const hasResultData = typeof toolResult?.result?.total_matches === 'number' && Number.isFinite(toolResult.result.total_matches) && toolResult.result.total_matches >= 0; @@ -103,10 +104,10 @@ export const GrepSearchDisplay: React.FC = ({ details={hasDetails ? [ { label: `${t('toolCards.grepSearch.labelPattern')}:`, value: pattern }, { label: `${t('toolCards.grepSearch.labelPath')}:`, value: searchPath }, - { + ...(hasResultData ? [{ label: `${t('toolCards.grepSearch.labelStats')}:`, value: t('toolCards.grepSearch.matchesAndFiles', { matches: stats.matches, files: stats.files }), - }, + }] : []), ] : undefined} resultBlocks={resultBlocks} /> diff --git a/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx index 35cb2331be..aa56e0831b 100644 --- a/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx @@ -82,7 +82,8 @@ export const LSDisplay: React.FC = ({ }, [entries]); const directoryPath = getDirectoryPath(); - const hasDetails = status === 'completed' && entries.length > 0; + const textOutput = entries.length === 0 && typeof toolResult?.result?.content === 'string' ? toolResult.result.content : ''; + const hasDetails = (status === 'completed' || Boolean(toolItem.importedSource)) && (entries.length > 0 || textOutput.length > 0); const hasResultData = Array.isArray(toolResult?.result?.entries); const handleClick = useCallback(() => { @@ -109,13 +110,15 @@ export const LSDisplay: React.FC = ({ statusDescription={getToolCardStatusDescription(status, t, toolResult?.error)} details={hasDetails ? [ { label: `${t('toolCards.ls.labelPath')}:`, value: directoryPath }, - { + ...(hasResultData ? [{ label: `${t('toolCards.ls.labelStats')}:`, value: stats.directories > 0 ? t('toolCards.ls.filesAndDirs', { files: stats.files, directories: stats.directories }) : t('toolCards.ls.filesCount', { count: stats.files }), }, { label: `${t('toolCards.ls.labelSort')}:`, value: t('toolCards.ls.sortByModifiedTime') }, + ] : []), + ...(textOutput ? [{ label: t('toolCards.common.executionResult'), value: textOutput }] : []), ] : undefined} results={hasDetails ? entries.slice(0, 50).map((entry, index) => ({ icon: entry.is_dir ? 'directory' as const : 'file' as const, diff --git a/src/web-ui/src/flow_chat/tool-cards/README.md b/src/web-ui/src/flow_chat/tool-cards/README.md index baec1a8074..fbeeddc08a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/README.md +++ b/src/web-ui/src/flow_chat/tool-cards/README.md @@ -2,6 +2,37 @@ This document captures UI behavior conventions for Flow Chat tool cards. +## Imported session tools + +Imported Codex and Zcode history uses the existing native card registry. A pure +read-time projection matches tool names and semantics, then adapts arguments and +results to each card's contract. Examples: Codex `exec` -> `RunCode`, shell calls +and Zcode `Bash` -> `ExecCommand`, file reads/writes/edits -> `Read`/`Write`/`Edit`, +and plans -> `TodoWrite`. Search, fetch, and image tools reuse their native cards. +Unmatched tools, including multi-file patches and code-cell waits, use the existing +`DefaultToolCard`; they must not be assigned to a different native workflow merely +because the names sound similar. Mixed-operation `web.run` also stays generic. + +The Turn's existing `userMessage.metadata.importedFrom` supplies provenance, so +old imports gain this presentation without migration. Native continuation Turns +keep their own runtime behavior. Original names, arguments, results and IDs remain +unchanged in persisted history; the projected clone is never written back. + +Imported cards use recorded payloads and cannot approve a call, open a source path +against the current filesystem, run a plan, or query/modify native snapshots. +Imported file changes stay outside native snapshot revision groups. Standard card +reader state, expansion, result previews and height contracts are shared, with no +separate imported-card UI and no new viewport writes. + +Focused contracts: + +```bash +pnpm --dir src/web-ui run test:run src/flow_chat/utils/importedToolPresentation.test.ts src/flow_chat/components/FlowToolCard.test.tsx src/flow_chat/tool-cards/RunCodeToolCard.test.tsx src/flow_chat/tool-cards/FileOperationToolCard.test.tsx src/flow_chat/store/FlowChatStore.test.ts +``` + +These tests cover projection, native rendering, history action isolation and old +payload round trips. They do not establish visual fidelity or remote runtime behavior. + ## OpenBitFun controls `OpenBitFunControlToolCard` composes the public Ambient and Prominent frameworks. diff --git a/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.test.tsx index 8a6ff7d5a2..63f2e46d34 100644 --- a/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { JSDOM } from 'jsdom'; import { RunCodeToolCard } from './RunCodeToolCard'; +import { projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -111,6 +112,21 @@ describe('RunCodeToolCard', () => { expect(container.textContent).toContain('Explore project root structure'); }); + it('renders imported Codex programs through the same code card with failure output', () => { + const original = runCodeItem({ + importedSource: 'codex', toolName: 'exec', + toolCall: { id: 'codex-call', input: 'text(await tools.exec_command({cmd: "pwd"}));' }, + toolResult: { success: true, result: [{ type: 'input_text', text: 'Script failed\nWall time 0.1 seconds\nOutput:\npermission denied' }] }, + }); + render(projectEffectiveToolItem(original)); + expand(); + expect(container.querySelector('[data-openbitfun-adapter="run-code-tool-card"]')).not.toBeNull(); + expect(container.textContent).toContain('text(await tools.exec_command'); + expect(container.textContent).toContain('permission denied'); + expect(container.querySelector('[data-openbitfun-status="error"]')).not.toBeNull(); + expect(original.toolName).toBe('exec'); + }); + it('falls back to the first real line of the program when there is no description', () => { render(runCodeItem({ toolCall: { diff --git a/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx index adfa320f9c..efdfd1b5f1 100644 --- a/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx @@ -113,7 +113,7 @@ export const RunCodeToolCard: React.FC = ({ program={code ? ( ({ + read: vi.fn<(...args: unknown[]) => Array<{ content: string; status: string }>>(() => []), +})); vi.mock('react-i18next', async (importOriginal) => ({ ...await importOriginal(), @@ -20,7 +25,7 @@ vi.mock('react-i18next', async (importOriginal) => ({ })); vi.mock('../hooks/useDialogTurnTodos', () => ({ - useDialogTurnTodos: () => [], + useDialogTurnTodos: (...args: unknown[]) => todoHook.read(...args), })); globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -77,6 +82,7 @@ describe('TodoWriteDisplay expansion', () => { let root: Root; beforeEach(() => { + todoHook.read.mockReset().mockReturnValue([]); container = document.createElement('div'); document.body.append(container); root = createRoot(container); @@ -134,6 +140,20 @@ describe('TodoWriteDisplay expansion', () => { expect(container.querySelector('[data-openbitfun-part="todoList"]')).toBeNull(); }); + it('shows the recorded imported plan through the native todo card without current-session tasks', () => { + todoHook.read.mockReturnValue([{ content: 'Current session task', status: 'in_progress' }]); + const toolItem = projectEffectiveToolItem({ + id: 'foreign-plan', type: 'tool', timestamp: 1, status: 'completed', importedSource: 'codex', toolName: 'update_plan', + toolCall: { id: 'foreign-plan', input: { plan: [{ step: 'Recorded plan task', status: 'in_progress' }] } }, + toolResult: { success: true, result: 'Plan updated' }, + }); + act(() => root.render()); + expect(container.querySelector('[data-openbitfun-adapter="todo-write"]')).not.toBeNull(); + expect(container.textContent).toContain('Recorded plan task'); + expect(container.textContent).not.toContain('Current session task'); + expect(todoHook.read).toHaveBeenCalledWith(undefined, 'turn'); + }); + it('shows the first task when all tasks are pending', () => { act(() => { root.render( diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx index a5955e3645..9f79b76cb5 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx @@ -37,20 +37,23 @@ export const TodoWriteDisplay: React.FC = ({ toolName: toolItem.toolName, }); - const turnTodos = useDialogTurnTodos(sessionId, turnId); + const turnTodos = useDialogTurnTodos(toolItem.importedSource ? undefined : sessionId, turnId); const todosToDisplay: TodoLike[] = useMemo(() => { if (isParamsStreaming && partialParams?.todos && Array.isArray(partialParams.todos)) { return partialParams.todos as TodoLike[]; } - if (turnTodos.length > 0) { + if (!toolItem.importedSource && turnTodos.length > 0) { return turnTodos as TodoLike[]; } if (toolResult?.result?.todos && Array.isArray(toolResult.result.todos)) { return toolResult.result.todos as TodoLike[]; } + if (Array.isArray(toolItem.toolCall?.input?.todos)) { + return toolItem.toolCall.input.todos as TodoLike[]; + } return []; - }, [partialParams, toolResult, isParamsStreaming, turnTodos]); + }, [partialParams, toolResult, isParamsStreaming, turnTodos, toolItem.importedSource, toolItem.toolCall?.input]); const todoRenderItems = useMemo( () => createTodoRenderItems(todosToDisplay), diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolCardInformation.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ToolCardInformation.test.tsx index 4a2429d00a..b9e90bbd3b 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ToolCardInformation.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ToolCardInformation.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom -import React from 'react'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; import { renderToStaticMarkup } from 'react-dom/server'; import { createInstance } from 'i18next'; import { I18nextProvider } from 'react-i18next'; @@ -17,9 +18,11 @@ import { LSDisplay } from './LSDisplay'; import { ReadFileDisplay } from './ReadFileDisplay'; import { ContextCompressionDisplay } from './ContextCompressionDisplay'; import { getToolCapsuleSummary } from './toolCapsuleModel'; +import { projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; const translations = createInstance(); beforeAll(async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; await translations.init({ lng: 'en-US', resources: { 'en-US': { 'flow-chat': en } }, interpolation: { escapeValue: false } }); }); @@ -36,6 +39,31 @@ function render(Component: React.ComponentType, toolItem: FlowToo } describe('collapsed tool information', () => { + it.each([ + ['Grep', GrepSearchDisplay, { pattern: 'needle' }, 'src/a.ts:3:needle', '0 matches'], + ['Glob', GlobSearchDisplay, { pattern: '*.ts' }, '/remote/src/a.ts', '0 files'], + ['LS', LSDisplay, { path: '/remote' }, 'src/\nREADME.md', '0 entries'], + ] as const)('shows imported plain-text %s results in its existing native card without invented counts', (name, Component, input, output, emptyLabel) => { + const toolItem = projectEffectiveToolItem({ ...item(name, input, output), importedSource: 'zcode' }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + try { + act(() => root.render( + + )); + expect(container.textContent).not.toContain(emptyLabel); + const surface = container.querySelector('[data-openbitfun-part="surface"]'); + expect(surface).not.toBeNull(); + act(() => surface!.click()); + expect(container.textContent).toContain(name === 'Grep' ? 'needle' : output); + expect(container.textContent).not.toContain(emptyLabel); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + it('keeps the computer action, target and application without generic success prose', () => { const call = item('ComputerUse', { action: 'type_text', text: 'Release notes' }, JSON.stringify({ computer_use_context: { foreground_application: { name: 'Editor' } } })); diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts index 01d5633964..37e21b8f7d 100644 --- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts +++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts @@ -15,6 +15,7 @@ import { isShellToolName } from '../grouping/activityClassification'; import { getControlHubInput, isControlHubObservation } from './runtimeToolCardModel'; import { SEMANTIC_BUILTIN_TOOL_NAMES, getBuiltinToolInput, getBuiltinToolAttention, isSemanticBuiltinTool } from './builtinToolCardPolicy'; import { readInteractionInput, readToolRecord } from './toolInteractionModel'; +import { importedToolCardName } from '../utils/importedToolPresentation'; type ToolCardDefinition = Omit; @@ -635,7 +636,11 @@ export function getToolCardConfig(toolName: string, input?: unknown): ToolCardCo } // Match by name or fall back to defaults. - return TOOL_CARD_CONFIGS[toolName] || { + return TOOL_CARD_CONFIGS[toolName] || getDefaultToolCardConfig(toolName); +} + +function getDefaultToolCardConfig(toolName: string): ToolCardConfig { + return { ...getToolCardClassification(toolName), toolName, displayName: `Tool: ${toolName}`, @@ -652,6 +657,9 @@ export function getToolCardConfig(toolName: string, input?: unknown): ToolCardCo export function getToolItemCardConfig( toolItem: FlowToolItem, ): ToolCardConfig { + if (toolItem.importedSource && !importedToolCardName(toolItem)) { + return { ...getDefaultToolCardConfig(toolItem.toolName), attention: 'ambient', presentation: 'standard' }; + } const effective = projectEffectiveToolItem(toolItem); const config = getToolCardConfig(effective.toolName, effective.toolName === 'OpenBitFunControl' ? getOpenBitFunControlInput(effective) diff --git a/src/web-ui/src/flow_chat/tool-cards/useFileOperationDiffStats.ts b/src/web-ui/src/flow_chat/tool-cards/useFileOperationDiffStats.ts index 6575593005..e1f971945c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/useFileOperationDiffStats.ts +++ b/src/web-ui/src/flow_chat/tool-cards/useFileOperationDiffStats.ts @@ -22,7 +22,7 @@ export function useFileOperationDiffStats(items: readonly FlowToolItem[], { const [snapshotStats, setSnapshotStats] = useState(); const dispatchSession = hasSessionFileProvider(sessionId); const operationIdsKey = JSON.stringify(sessionId && !dispatchSession ? items - .filter(item => getToolCardStatus(item) === 'completed' + .filter(item => !item.importedSource && getToolCardStatus(item) === 'completed' && (snapshotsAvailable || item.toolResult?.result?.snapshot_recorded === true)) .map(item => item.toolCall?.id).filter(Boolean) : []); const operationIds = useMemo(() => new Set(JSON.parse(operationIdsKey)), [operationIdsKey]); diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 3394300f8d..aa7011afbd 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -52,6 +52,8 @@ export interface FlowToolItem extends FlowItem { type: 'tool'; /** Provider-facing identity. Deferred calls remain `CallDeferredTool`. */ toolName: string; + /** Read-time provenance from the owning Turn, never a native runtime tool. */ + importedSource?: 'codex' | 'zcode'; terminalSessionId?: string; interruptionReason?: 'app_restart' | 'retry_superseded'; toolCall: { @@ -390,6 +392,19 @@ export type SessionContextRestoreState = // Session state. export interface Session { + /** Original history source; continuation mode is an independent fact. */ + importedSource?: string; + /** Local composer selection, applied only after a source view is forked. */ + externalContinuationPermissionMode?: import('@/infrastructure/api/service-api/AgentAPI').SessionPermissionMode | null; + externalHistory?: { + mode: 'follow'; + contentVersion?: string; + sourceStatus?: 'available' | 'unavailable'; + incomplete?: boolean; + observedFingerprint?: string; + materializedObservation?: string; + }; + externalBranchOf?: import('@/shared/types/session-history').SessionCustomMetadata['externalBranchOf']; sessionId: string; title?: string; /** diff --git a/src/web-ui/src/flow_chat/utils/externalHistory.test.ts b/src/web-ui/src/flow_chat/utils/externalHistory.test.ts new file mode 100644 index 0000000000..136f464614 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/externalHistory.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { externalHistoryFromMetadata, readComposerPermissionMode, viewedExternalForkTurn } from './externalHistory'; +import type { Session } from '../types/flow-chat'; + +describe('external history views', () => { + it.each([null, 'ask', 'auto_approve', 'full_access'] as const)('reads source permission selection %s without restoring a runtime', async mode => { + const nativeRead = vi.fn().mockRejectedValue(new Error('Source history is read-only')); + expect(await readComposerPermissionMode({ externalHistory: { mode: 'follow' }, externalContinuationPermissionMode: mode }, nativeRead)) + .toEqual({ mode }); + expect(nativeRead).not.toHaveBeenCalled(); + }); + + it('reads the native branch mode and preserves actual read failures', async () => { + const nativeRead = vi.fn().mockResolvedValueOnce({ mode: 'ask' }).mockRejectedValueOnce(new Error('Host offline')); + expect(await readComposerPermissionMode({}, nativeRead)).toEqual({ mode: 'ask' }); + await expect(readComposerPermissionMode({}, nativeRead)).rejects.toThrow('Host offline'); + }); + it('keeps legacy imports independent unless following is explicit', () => { + expect(externalHistoryFromMetadata({ importedFrom: 'codex' })).toBeUndefined(); + expect(externalHistoryFromMetadata({ externalHistoryMode: 'follow', externalHistoryPending: false })).toBeUndefined(); + expect(externalHistoryFromMetadata({ externalHistoryMode: 'follow', externalHistoryPending: true, externalContentVersion: 'new' })) + .toMatchObject({ mode: 'follow', contentVersion: 'new' }); + }); + + it('pins continuation to the viewed turn even when newer metadata arrives', () => { + const session = { + historyState: 'ready', externalHistory: { mode: 'follow', contentVersion: 'new' }, + dialogTurns: [{ id: 'old-turn', userMessage: { metadata: { externalContentVersion: 'old' } } }], + turnCatalog: { entries: [{ turnId: 'old-turn' }] }, + } as Session; + expect(viewedExternalForkTurn(session)).toBe('old-turn'); + session.historyState = 'hydrating'; + expect(viewedExternalForkTurn(session)).toBeUndefined(); + session.historyState = 'ready'; + session.turnCatalog!.entries[0].turnId = 'unloaded-tail'; + expect(viewedExternalForkTurn(session)).toBeUndefined(); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/externalHistory.ts b/src/web-ui/src/flow_chat/utils/externalHistory.ts new file mode 100644 index 0000000000..ed80a4dc58 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/externalHistory.ts @@ -0,0 +1,39 @@ +import type { SessionCustomMetadata } from '@/shared/types/session-history'; +import type { Session } from '../types/flow-chat'; +import type { SessionPermissionModeResponse } from '@/infrastructure/api/service-api/AgentAPI'; + +/** Source views have no runtime permissions; selection is applied to the first branch. */ +export async function readComposerPermissionMode( + session: Pick | undefined, + readNative: () => Promise, +): Promise { + return session?.externalHistory?.mode === 'follow' + ? { mode: session.externalContinuationPermissionMode ?? null } + : readNative(); +} + +export function externalHistoryFromMetadata(metadata?: SessionCustomMetadata): Session['externalHistory'] { + if (metadata?.externalHistoryMode !== 'follow' || metadata.externalHistoryPending !== true) return undefined; + return { + mode: 'follow', + contentVersion: metadata.externalContentVersion, + sourceStatus: metadata.externalSourceStatus, + incomplete: metadata.externalSourceIncomplete, + observedFingerprint: metadata.externalObservedFingerprint, + materializedObservation: metadata.externalMaterializedObservation, + }; +} + +export function viewedExternalHistoryVersion(session?: Session): string | undefined { + const version = session?.dialogTurns.at(-1)?.userMessage.metadata?.externalContentVersion; + return typeof version === 'string' ? version : undefined; +} + +export function viewedExternalForkTurn(session: Session): string | undefined { + if (session.historyState !== 'ready' || !viewedExternalHistoryVersion(session)) return undefined; + const last = session.dialogTurns.at(-1); + const catalogTail = session.turnCatalog?.entries.at(-1)?.turnId; + // A paged older window is not the displayed source's continuation boundary. + if (catalogTail && last?.id !== catalogTail) return undefined; + return last?.id; +} diff --git a/src/web-ui/src/flow_chat/utils/flowItemVisibility.ts b/src/web-ui/src/flow_chat/utils/flowItemVisibility.ts index 42820397c8..8aa8a7f1fa 100644 --- a/src/web-ui/src/flow_chat/utils/flowItemVisibility.ts +++ b/src/web-ui/src/flow_chat/utils/flowItemVisibility.ts @@ -4,6 +4,7 @@ import { getEffectiveToolName } from './toolInvocationIdentity'; /** Shared by leaf cards and transcript composition so hidden cards leave no row. */ export function isToolCardVisible(toolItem: FlowToolItem): boolean { + if (toolItem.importedSource) return true; const failed = toolItem.status === 'error' || (toolItem.status === 'completed' && toolItem.toolResult?.success === false); if (!failed) return true; diff --git a/src/web-ui/src/flow_chat/utils/importedToolPresentation.test.ts b/src/web-ui/src/flow_chat/utils/importedToolPresentation.test.ts new file mode 100644 index 0000000000..f8315d2cb6 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/importedToolPresentation.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowToolItem } from '../types/flow-chat'; +import { importedToolCardName, importedToolOutput } from './importedToolPresentation'; +import { getEffectiveToolName, projectEffectiveToolItem } from './toolInvocationIdentity'; +import { isToolCardVisible } from './flowItemVisibility'; +import { isShellActivity, shellActivityStats } from '../grouping/activityClassification'; +import { fileEditTarget } from '../grouping/fileEdits'; +import { getToolItemCardConfig } from '../tool-cards/toolCardMetadata'; + +function item(toolName: string, input: unknown, result: unknown, importedSource: FlowToolItem['importedSource'] = 'codex'): FlowToolItem { + return { + id: 'call', type: 'tool', toolName, importedSource, status: 'completed', timestamp: 1, + toolCall: { id: 'call', input }, toolResult: { success: true, result }, + }; +} + +describe('imported tool projection to native cards', () => { + it.each([ + ['exec', 'RunCode'], ['functions.exec', 'RunCode'], ['exec_command', 'ExecCommand'], + ['functions.shell_command', 'ExecCommand'], ['shell', 'ExecCommand'], ['write_stdin', 'WriteStdin'], + ['Read', 'Read'], ['Write', 'Write'], ['Edit', 'Edit'], ['Grep', 'Grep'], ['Glob', 'Glob'], + ['LS', 'LS'], ['WebSearch', 'WebSearch'], ['WebFetch', 'WebFetch'], ['view_image', 'view_image'], + ['update_plan', 'TodoWrite'], + ])('matches Codex %s to the native %s contract', (name, native) => { + expect(importedToolCardName(item(name, {}, null))).toBe(native); + }); + + it.each(['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'LS', 'WebSearch', 'WebFetch', 'TodoWrite'])('matches the Zcode %s contract', name => { + expect(importedToolCardName(item(name, {}, null, 'zcode'))).toBe(name === 'Bash' ? 'ExecCommand' : name); + }); + + it('projects freeform code and every result block without rewriting the stored record', () => { + const tool = item('exec', 'const x = await tools.exec_command({cmd: "pwd"});\ntext(x);', [ + { type: 'input_text', text: 'Script completed\nWall time: 1.2 seconds\nOutput:\n/workspace' }, + { type: 'input_text', text: 'second output' }, + { type: 'resource', uri: 'source://output' }, + ]); + const original = JSON.stringify(tool); + const projected = projectEffectiveToolItem(tool); + expect(projected).toMatchObject({ toolName: 'RunCode', toolCall: { input: { code: tool.toolCall.input, language: 'javascript' } } }); + expect(projected.toolResult?.result.output).toContain('second output'); + expect(projected.toolResult?.result.output).toContain('source://output'); + expect(projected.toolResult?.result.output).not.toContain('Script completed'); + expect(projectEffectiveToolItem(tool)).toBe(projected); + expect(projectEffectiveToolItem(projected)).toBe(projected); + expect(JSON.stringify(tool)).toBe(original); + expect(projectEffectiveToolItem({ ...tool, toolCall: { ...tool.toolCall, input: 'text(2)' } }).toolCall.input.code).toBe('text(2)'); + }); + + it.each(['clock.sleep', 'functions.wait', 'apply_patch', 'mcp__example__exec', '__proto__', 'constructor', 'CallDeferredTool', 'AskUserQuestion'])('uses a generic card for unmatched %s instead of a superficially similar workflow', name => { + expect(importedToolCardName(item(name, { tool_name: 'Write', args: {} }, null))).toBeUndefined(); + expect(getToolItemCardConfig(item(name, {}, null))).toMatchObject({ toolName: name, requiresConfirmation: false, presentation: 'standard' }); + }); + + it('does not change native tools or unwrap a foreign deferred gateway', () => { + const native = { ...item('exec', 'text(1)', null), importedSource: undefined }; + expect(getEffectiveToolName(native)).toBe('exec'); + expect(projectEffectiveToolItem(native)).toBe(native); + expect(getEffectiveToolName(item('CallDeferredTool', { tool_name: 'Write', args: {} }, null))).toBe('CallDeferredTool'); + }); + + it('uses native shell summaries, including recorded failure and elapsed time', () => { + const tool = item('functions.exec_command', { cmd: 'cargo check', workdir: '/remote/project' }, + 'Chunk ID: test\nWall time: 0.42 seconds\nProcess exited with code 1\nFinal output:\ncompile failed'); + expect(projectEffectiveToolItem(tool)).toMatchObject({ + toolName: 'ExecCommand', status: 'error', + toolResult: { success: false, result: { exit_code: 1, wall_time_seconds: 0.42, output: 'compile failed' } }, + }); + expect(isShellActivity(tool)).toBe(true); + expect(shellActivityStats([tool])).toMatchObject({ commands: 1, failed: 1, running: 0 }); + }); + + it('adapts Zcode Bash and legacy JSON arguments without inventing process metadata', () => { + const bash = projectEffectiveToolItem(item('Bash', { command: 'git status', description: 'Check changes' }, 'working tree clean', 'zcode')); + expect(bash.toolCall.input).toMatchObject({ cmd: 'git status', description: 'Check changes' }); + expect(bash.toolResult?.result).toEqual({ output: 'working tree clean', exit_code: undefined, wall_time_seconds: undefined }); + const shell = projectEffectiveToolItem(item('shell_command', '{"command":"pwd"}', JSON.stringify({ output: '/remote', exit_code: 0, wall_time_seconds: 0.2 }))); + expect(shell.toolCall.input.cmd).toBe('pwd'); + expect(shell.toolResult?.result).toMatchObject({ output: '/remote', exit_code: 0, wall_time_seconds: 0.2 }); + const poll = item('write_stdin', { session_id: 19, chars: '' }, { output: 'done', exit_code: 0 }); + expect(getToolItemCardConfig(poll).presentation).toBe('relation'); + expect(shellActivityStats([poll])).toMatchObject({ commands: 0, interactions: 1 }); + }); + + it('honors failed script envelopes without guessing from arbitrary output or file contents', () => { + expect(projectEffectiveToolItem(item('exec', 'bad()', [ + { type: 'input_text', text: 'Script failed\nWall time 0.1 seconds\nOutput:\nReferenceError' }, + ])).status).toBe('error'); + expect(projectEffectiveToolItem(item('exec', 'store(x)', [ + { type: 'input_text', text: 'Script error:\nOnly plain serializable objects can be stored.' }, + ])).status).toBe('error'); + expect(projectEffectiveToolItem(item('exec', 'text(sample)', 'A sample says:\nScript failed\nOutput:\nexample')).status).toBe('completed'); + expect(projectEffectiveToolItem(item('exec', 'text(sample)', 'Script completed\nWall time: 1 seconds\nOutput:\nScript failed')).status).toBe('completed'); + expect(projectEffectiveToolItem(item('exec', 'text(sample)', 'Script completed\nWall time: 1 seconds\nOutput:\nScript error:\nexample')).status).toBe('completed'); + expect(projectEffectiveToolItem(item('Read', { file_path: '/remote/result.json' }, + '{"exit_code":1,"wall_time_seconds":2,"is_error":true}', 'zcode')).status).toBe('completed'); + }); + + it('retains empty replacements and remote paths without entering native snapshot groups', () => { + const edit = item('Edit', { file_path: '/remote/src/a.ts', old_string: 'remove me', new_string: '' }, 'File updated', 'zcode'); + expect(projectEffectiveToolItem(edit).toolCall.input).toEqual(edit.toolCall.input); + expect(fileEditTarget(edit)).toBeUndefined(); + expect(getToolItemCardConfig(edit)).toMatchObject({ attention: 'prominent', presentation: 'standard', requiresConfirmation: false }); + expect(fileEditTarget({ ...edit, importedSource: undefined })).toBeDefined(); + }); + + it('maps web searches and todo plans only when their operation is equivalent', () => { + const search = item('web.run', { search_query: [{ q: 'Rust ownership' }] }, 'results'); + expect(importedToolCardName(search)).toBe('WebSearch'); + expect(projectEffectiveToolItem(search)).toMatchObject({ toolCall: { input: { query: 'Rust ownership' } }, toolResult: { result: { content: 'results' } } }); + expect(importedToolCardName(item('web.run', { finance: [{ ticker: 'BTC' }] }, 'quote'))).toBeUndefined(); + expect(importedToolCardName(item('web.run', { search_query: [{ q: 'Rust' }], open: [{ ref_id: 'url' }] }, 'mixed'))).toBeUndefined(); + const todos = [{ content: 'Check code', status: 'in_progress' }]; + expect(projectEffectiveToolItem(item('update_plan', { plan: [{ step: 'Check code', status: 'in_progress' }] }, 'Plan updated')).toolResult?.result.todos).toEqual(todos); + expect(projectEffectiveToolItem(item('TodoWrite', { todos }, 'Done', 'zcode')).toolResult?.result.todos).toEqual(todos); + }); + + it.each(['Read', 'WebSearch', 'WebFetch'])('keeps imported %s failures visible', toolName => { + const tool = { ...item(toolName, {}, 'partial result', 'zcode'), status: 'error' as const }; + expect(isToolCardVisible(projectEffectiveToolItem(tool))).toBe(true); + expect(isToolCardVisible({ ...tool, importedSource: undefined })).toBe(false); + }); + + it('keeps raw generic fields and text output available, with no source approval', () => { + const tool = { ...item('unknown', { _source: 'value' }, [{ type: 'text', text: 'A' }, { type: 'output_text', text: 'B' }]), requiresConfirmation: true }; + expect(projectEffectiveToolItem(tool)).toMatchObject({ requiresConfirmation: false, toolCall: { input: { _source: 'value' } }, toolResult: { result: 'A\n\nB' } }); + expect(importedToolOutput({ future_shape: [1, false] })).toContain('future_shape'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/importedToolPresentation.ts b/src/web-ui/src/flow_chat/utils/importedToolPresentation.ts new file mode 100644 index 0000000000..3f6940c84d --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/importedToolPresentation.ts @@ -0,0 +1,188 @@ +import type { FlowToolItem } from '../types/flow-chat'; + +type ImportedToolIdentity = Pick; +type NativeCardName = 'RunCode' | 'ExecCommand' | 'WriteStdin' | 'Read' | 'Write' | 'Edit' + | 'Grep' | 'Glob' | 'LS' | 'WebSearch' | 'WebFetch' | 'TodoWrite' | 'view_image'; + +// Match contracts as well as names. Product workflow cards (approval, subagents, +// deployment, etc.) require native runtime state that foreign history cannot supply. +const nativeNames = new Map([ + ['runcode', 'RunCode'], ['execcommand', 'ExecCommand'], ['writestdin', 'WriteStdin'], + ['bash', 'ExecCommand'], ['read', 'Read'], ['write', 'Write'], ['edit', 'Edit'], + ['grep', 'Grep'], ['glob', 'Glob'], ['ls', 'LS'], ['websearch', 'WebSearch'], + ['webfetch', 'WebFetch'], ['todowrite', 'TodoWrite'], ['view_image', 'view_image'], +]); +const codexAliases = new Map([ + ['exec', 'RunCode'], ['exec_command', 'ExecCommand'], ['shell_command', 'ExecCommand'], + ['shell', 'ExecCommand'], ['write_stdin', 'WriteStdin'], ['update_plan', 'TodoWrite'], +]); + +function record(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record : {}; +} + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function inputRecord(value: unknown): Record { + if (typeof value === 'string') { + try { return record(JSON.parse(value)); } catch { return {}; } + } + return record(value); +} + +/** Text blocks become readable text; unknown blocks remain visible as JSON. */ +export function importedToolOutput(value: unknown): string { + if (value == null) return ''; + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(importedToolOutput).join('\n\n'); + const block = record(value); + if (['text', 'input_text', 'output_text'].includes(String(block.type)) + && typeof block.text === 'string') return block.text; + if (Array.isArray(block.content)) return importedToolOutput(block.content); + if (typeof block.output === 'string') return block.output; + if (typeof block.stdout === 'string' || typeof block.stderr === 'string') { + return [text(block.stdout), text(block.stderr)].filter(Boolean).join('\n'); + } + return JSON.stringify(value, null, 2); +} + +/** Undefined means use DefaultToolCard, even if an unrelated native name collides. */ +export function importedToolCardName(item: ImportedToolIdentity): NativeCardName | undefined { + if (!item.importedSource) return undefined; + const name = item.importedSource === 'codex' ? item.toolName.replace(/^functions\./, '') : item.toolName; + if (item.importedSource === 'codex') { + const alias = codexAliases.get(name); + if (alias) return alias; + if (name === 'web.run' || name === 'web__run') { + const input = inputRecord(item.toolCall?.input); + // web.run also handles finance, images, weather, navigation, etc.; only + // an actual search belongs in the existing WebSearch card. + if (Array.isArray(input.search_query) && input.search_query.length > 0 + && input.search_query.every(query => typeof record(query).q === 'string') + && Object.keys(input).every(key => key === 'search_query' || key === 'response_length')) return 'WebSearch'; + return undefined; + } + } + return nativeNames.get(name.toLowerCase()); +} + +export function importedToolDisplayName(item: ImportedToolIdentity): string { + return importedToolCardName(item) ?? item.toolName; +} + +function processResult(raw: unknown): Record { + const parsed = inputRecord(raw); + return ['output', 'stdout', 'stderr', 'exit_code', 'wall_time_seconds', 'session_id'] + .some(key => key in parsed) ? parsed : {}; +} + +const projections = new WeakMap(); + +/** Display-only conversion to existing card contracts. Never persist this clone. */ +export function projectImportedToolItem(item: FlowToolItem): FlowToolItem { + if (!item.importedSource) return item; + const cached = projections.get(item); + if (cached) return cached; + const card = importedToolCardName(item); + const rawInput: unknown = item.toolCall?.input; + const input = inputRecord(rawInput); + const rawResult: unknown = item.toolResult?.result; + const isProcess = card === 'ExecCommand' || card === 'WriteStdin'; + const result = isProcess ? processResult(rawResult) : record(rawResult); + let output = importedToolOutput(isProcess && Object.keys(result).length > 0 ? result : rawResult); + let exitCode = isProcess ? finiteNumber(result.exit_code) : undefined; + let elapsedSeconds = isProcess ? finiteNumber(result.wall_time_seconds) : undefined; + let failed = record(rawResult).is_error === true || record(rawResult).isError === true; + + if (item.importedSource === 'codex') { + // Inspect only known envelope headers, never error examples in program output. + if (card === 'RunCode') failed ||= /^Script error:\r?\n/.test(output); + const headerEnd = output.search(/\r?\n(?:Output|Final output):/); + const header = headerEnd >= 0 ? output.slice(0, headerEnd) : ''; + const legacyProcess = /^(?:Chunk ID: [^\r\n]+\r?\n)?Wall time: [\d.]+ seconds\r?\nProcess /.test(header); + const script = /^Script (?:completed|failed|running)\b/.test(header); + const scriptTool = card === 'RunCode' || /^(?:functions\.)?wait$/.test(item.toolName); + if ((isProcess && legacyProcess) || (scriptTool && script)) { + const exit = header.match(/(?:Process exited with code|exit code[: ]+)\s*(-?\d+)/i); + const elapsed = header.match(/^Wall time:? ([\d.]+) seconds/m); + exitCode ??= exit ? Number(exit[1]) : undefined; + elapsedSeconds ??= elapsed ? Number(elapsed[1]) : undefined; + failed ||= /^Script failed\b/.test(header); + output = output.slice(headerEnd).replace(/^\r?\n(?:Output|Final output):\r?\n?/, ''); + } + } + failed ||= exitCode !== undefined && exitCode !== 0; + const status = item.status === 'completed' && (failed || item.toolResult?.success === false) ? 'error' : item.status; + // Native failure cards consume `error` instead of their ordinary result view. + // Keep the source's diagnostic and partial output together in that contract. + const error = status === 'error' + ? [...new Set([item.toolResult?.error, output].filter(Boolean))].join('\n\n') || undefined + : item.toolResult?.error; + const path = text(input.file_path) || text(input.path) || text(input.absolute_path); + let nativeInput: unknown = card && Object.keys(input).length > 0 ? input : rawInput; + let nativeResult: unknown = rawResult; + switch (card) { + case 'RunCode': + nativeInput = { ...input, code: text(input.code) || text(rawInput), language: text(input.language) || 'javascript' }; + nativeResult = { ...result, output }; + break; + case 'ExecCommand': + case 'WriteStdin': + nativeInput = card === 'ExecCommand' ? { + ...input, + cmd: text(input.cmd) || text(input.command) || (Array.isArray(input.command) ? input.command.map(String).join(' ') : ''), + } : input; + nativeResult = { ...result, output, exit_code: exitCode, wall_time_seconds: elapsedSeconds }; + break; + case 'Read': case 'Write': case 'Edit': + nativeInput = { ...input, file_path: path }; + break; + case 'view_image': + nativeInput = { ...input, path }; + break; + case 'Grep': + nativeResult = { ...result, result: typeof result.result === 'string' ? result.result : output }; + break; + case 'Glob': case 'LS': + nativeResult = Array.isArray(rawResult) ? rawResult : { ...result, content: text(result.content) || output }; + break; + case 'WebSearch': + nativeInput = { ...input, query: text(input.query) || text(input.search_term) + || (Array.isArray(input.search_query) ? input.search_query.map(query => text(record(query).q)).join(' · ') : '') }; + nativeResult = { ...result, content: text(result.content) || output }; + break; + case 'WebFetch': + nativeResult = { ...result, url: text(result.url) || text(input.url), content: text(result.content) || output }; + break; + case 'TodoWrite': { + const todos = Array.isArray(input.todos) ? input.todos : Array.isArray(input.plan) + ? input.plan.map(step => ({ content: record(step).step, status: record(step).status })) : []; + nativeInput = { ...input, todos }; + nativeResult = { ...result, todos: Array.isArray(result.todos) ? result.todos : todos }; + break; + } + default: + // Generic cards retain the complete input shape; flatten only provider text blocks. + if (Array.isArray(rawResult) && rawResult.some(value => ['text', 'input_text', 'output_text'].includes(String(record(value).type)))) nativeResult = output; + } + + const projected: FlowToolItem = { + ...item, toolName: card ?? item.toolName, status, + toolCall: { ...item.toolCall, input: nativeInput }, + toolResult: item.toolResult ? { + ...item.toolResult, result: nativeResult, error, success: failed ? false : item.toolResult.success, + } : undefined, + // An old source permission must never become an actionable native permission. + requiresConfirmation: false, acpPermission: undefined, + }; + projections.set(item, projected); + projections.set(projected, projected); + return projected; +} diff --git a/src/web-ui/src/flow_chat/utils/toolInvocationIdentity.ts b/src/web-ui/src/flow_chat/utils/toolInvocationIdentity.ts index 0d8a0e5cda..a88d1d086a 100644 --- a/src/web-ui/src/flow_chat/utils/toolInvocationIdentity.ts +++ b/src/web-ui/src/flow_chat/utils/toolInvocationIdentity.ts @@ -1,4 +1,5 @@ import type { FlowToolItem } from '../types/flow-chat'; +import { importedToolDisplayName, projectImportedToolItem } from './importedToolPresentation'; export const DEFERRED_TOOL_GATEWAY_NAME = 'CallDeferredTool'; @@ -58,11 +59,13 @@ export function effectiveToolInvocation( }; } -export function getEffectiveToolName(toolItem: Pick): string { +export function getEffectiveToolName(toolItem: Pick): string { + if (toolItem.importedSource) return importedToolDisplayName(toolItem); return effectiveToolInvocation(toolItem.toolName, toolItem.toolCall?.input).toolName; } export function projectEffectiveToolItem(toolItem: FlowToolItem): FlowToolItem { + if (toolItem.importedSource) return projectImportedToolItem(toolItem); const effective = effectiveToolInvocation(toolItem.toolName, toolItem.toolCall?.input); if (!effective.isDeferred) { return toolItem; diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 850e2beb68..21a23abb86 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "a0070347f9527a6eddeb84b5cd0a93d345c378aa008ce857976f02a9f4bd3d2f" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "1cd850650aa8b8df165b6fe69c90f060c240169f4becf163ecaba3fbe037000b" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index a6c2fabe0b..2868e554c9 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:01a8acb268a90398" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:b89f0fab51cf3cff" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSessionImportAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSessionImportAPI.test.ts new file mode 100644 index 0000000000..ae228d19b0 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSessionImportAPI.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock('./ExternalSourcesAPI', () => ({ invokeExternalSourceCommand: mocks.invoke })); +import { externalSessionImportAPI } from './ExternalSessionImportAPI'; + +describe('removed external sessions', () => { + beforeEach(() => mocks.invoke.mockReset()); + + it('validates the source and workspace identities of removal records', async () => { + const entry = { ecosystemId: 'codex', sessionId: 'removed', sessionName: 'Name', workspaceId: 'owner', workspaceName: 'Project' }; + mocks.invoke.mockResolvedValueOnce([entry]).mockResolvedValueOnce([{ ...entry, ecosystemId: 'zcode' }]); + expect(await externalSessionImportAPI.listRemoved('codex')).toEqual([entry]); + await expect(externalSessionImportAPI.listRemoved('codex')).rejects.toThrow('Invalid removed source'); + }); + + it('restores by stable host-owned workspace and source identifiers', async () => { + mocks.invoke.mockResolvedValue(undefined); + await externalSessionImportAPI.restoreRemoved('removed', 'owner', 'zcode'); + expect(mocks.invoke).toHaveBeenCalledExactlyOnceWith('restore_removed_external_session', { + request: { sessionId: 'removed', targetWorkspaceId: 'owner', ecosystemId: 'zcode' }, + }); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSessionImportAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSessionImportAPI.ts new file mode 100644 index 0000000000..2f8650a1d7 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSessionImportAPI.ts @@ -0,0 +1,30 @@ +import { invokeExternalSourceCommand } from './ExternalSourcesAPI'; + +export type ExternalSessionEcosystem = 'codex' | 'zcode'; +export interface RemovedExternalSession { + ecosystemId: ExternalSessionEcosystem; + sessionId: string; + sessionName: string; + workspaceId: string; + workspaceName: string; +} +export const externalSessionImportAPI = { + async listRemoved(ecosystemId: ExternalSessionEcosystem): Promise { + const entries = await invokeExternalSourceCommand('list_removed_external_sessions', { + request: { ecosystemId }, + }); + if (!Array.isArray(entries) || entries.some(entry => !entry || typeof entry !== 'object' + || entry.ecosystemId !== ecosystemId + || !['sessionId', 'sessionName', 'workspaceId', 'workspaceName'].every(key => typeof entry[key] === 'string') + || !entry.sessionId || !entry.workspaceId)) { + throw new Error('Invalid removed source session response'); + } + return entries as RemovedExternalSession[]; + }, + + restoreRemoved(sessionId: string, targetWorkspaceId: string, ecosystemId: ExternalSessionEcosystem): Promise { + return invokeExternalSourceCommand('restore_removed_external_session', { + request: { sessionId, targetWorkspaceId, ecosystemId }, + }); + }, +}; diff --git a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts index e189fd8bb7..81d9b56314 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts @@ -43,6 +43,8 @@ export interface SessionMetadataPage { hasMore: boolean; /** Absent on older hosts; never interpret absence as an empty snapshot. */ activities?: SessionActivitySummary[]; + discoveryWarnings?: string[]; + discoveryPending?: boolean; } export interface SessionLineageRequest { diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index 5dbc4c9d77..98e51968fa 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -40,6 +40,7 @@ import { toolGroupPickerAppearanceDescriptor } from '@/app/scenes/agents/compone import { inlineDiffPreviewAppearanceDescriptor } from '@/flow_chat/components/InlineDiffPreview.appearance'; import { exportImageAppearanceDescriptor } from '@/flow_chat/components/modern/ExportImageButton.appearance'; import { userMessageItemAppearanceDescriptor } from '@/flow_chat/components/modern/UserMessageItem.appearance'; +import { externalHistoryNoticeAppearanceDescriptor } from '@/flow_chat/components/ExternalHistoryNotice.appearance'; import { sessionUsageReportCardAppearanceDescriptor } from '@/flow_chat/components/usage/SessionUsageReportCard.appearance'; import { sessionUsageModalAppearanceDescriptor } from '@/flow_chat/components/usage/SessionUsageModal.appearance'; import { createPlanDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/CreatePlanDisplay.appearance'; @@ -298,6 +299,7 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(inlineDiffPreviewAppearanceDescriptor) .registerComponent(exportImageAppearanceDescriptor) .registerComponent(userMessageItemAppearanceDescriptor) + .registerComponent(externalHistoryNoticeAppearanceDescriptor) .registerComponent(sessionUsageReportCardAppearanceDescriptor) .registerComponent(sessionUsageModalAppearanceDescriptor) .registerComponent(createPlanDisplayAppearanceDescriptor) diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 36be70a8d8..5897931f9b 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -271,6 +271,15 @@ } }, "sessions": { + "removeSource": "Remove from OpenBitFun", + "removeSourceConfirmTitle": "Remove source session from OpenBitFun?", + "removeSourceConfirmMessage": "“{{name}}” will be removed from OpenBitFun. The original session and existing continuation branches are kept. You can add the source again in ecosystem compatibility settings.", + "removeSourceExplanation": "Source sessions are only removed from OpenBitFun; original sessions and continuation branches are kept and sources can be added again in ecosystem compatibility settings.", + "removeSelected": "Remove selected sources", + "bulkRemoveSourceConfirmTitle": "Remove selected source sessions?", + "bulkRemoveSourceConfirmMessage": "Remove {{count}} source sessions from OpenBitFun? Original sessions and existing continuation branches are kept. Sources can be added again in ecosystem compatibility settings.", + "removedSelected": "Removed {{count}} sessions from the list.", + "removedSourceSucceeded": "Removed “{{name}}” from OpenBitFun in {{workspace}}.", "newSession": "New session", "acpSessions": "ACP sessions", "dispatchRunningOn": "Runs on {{target}} ({{state}})", @@ -477,7 +486,12 @@ "deleteAllArchivedFailed": "Failed to delete all archived sessions", "archiveAllFailed": "Failed to archive sessions", "bulkArchiveFailed": "Failed to archive selected sessions", - "bulkDeleteFailed": "Failed to delete selected sessions" + "bulkDeleteFailed": "Failed to delete selected sessions", + "sourceAgentFilter": "Session source", + "allSourceAgents": "All agents", + "sourceAgentLabel": "Imported from {{name}}", + "discoveryPartial": "Some external sessions could not be discovered. Existing sessions remain available.", + "discoveringExternal": "Discovering external sessions…" }, "scheduledJobs": { "title": "Scheduled Jobs", diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 00860804bd..0eb9fd943c 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1,4 +1,20 @@ { + "externalHistory": { + "removed": "The source session was removed from OpenBitFun. This branch keeps its own history.", + "restoreSource": "Add source again", + "sourceMissing": "The source session is unavailable. This branch keeps its own history.", + "openFailed": "Could not open the source session. You can keep using this branch and retry later.", + "restoreFailed": "Could not add the source session again. This branch is unchanged; retry later.", + "unavailableWithoutCache": "The source is unavailable and no cached history is available. Restore the source and reopen this session.", + "unsupportedSurface": "Following source sessions is unavailable in Peer mode. Open the source on its host, or continue an existing OpenBitFun branch.", + "forkBeforeEdit": "Fork this source session before editing or rolling back its history.", + "followHint": "From {{source}} · Sending starts a new OpenBitFun branch", + "branchHint": "Continued from {{source}}", + "openSource": "Open source session", + "unavailable": "Source unavailable; showing the last cached view", + "incomplete": "The source conversation is unfinished; showing the available history", + "openBeforeContinue": "Open the source session and wait for its history to load before continuing." + }, "subagentIdentity": { "names": { "bouncyBean": "Bouncy Bean", diff --git a/src/web-ui/src/locales/en-US/scenes/ecosystem-compatibility.json b/src/web-ui/src/locales/en-US/scenes/ecosystem-compatibility.json index fcf0bea075..c3145a517f 100644 --- a/src/web-ui/src/locales/en-US/scenes/ecosystem-compatibility.json +++ b/src/web-ui/src/locales/en-US/scenes/ecosystem-compatibility.json @@ -68,6 +68,28 @@ "pet": "Pets", "instruction": "Instructions" }, + "sessions": { + "removed": { + "title": "Removed source sessions", + "description": "Add a source session back to the session list. Its existing OpenBitFun branches remain independent; history updates when you open the source.", + "empty": "No source sessions have been removed.", + "restore": "Add again", + "restoreNamed": "Add “{{name}}” again", + "retry": "Retry", + "loadFailed": "Could not load removed source sessions. Retry when the host is available.", + "restoreFailed": "Could not add this source session again. The removal record is kept; retry later.", + "refreshFailed": "The source was added again, but the session list could not refresh. Reopen the workspace to see it.", + "unsupportedHost": "This host does not support restoring removed source sessions. Update the serving host and retry.", + "unsupportedSurface": "Manage removed source sessions on their owning host. This action is unavailable in Peer mode." + }, + "autoDiscovery": { + "label": "Automatically discover {{name}} sessions", + "description": "When a workspace opens, list its {{name}} sessions using a lightweight index. The full history is saved locally the first time you open or continue a session. Unopened history requires the original source. Turning this off keeps existing entries and copies. SSH workspace discovery is not available.", + "unsupported": "This host does not support automatic session discovery. Update the serving host to enable it.", + "loadFailed": "Could not load automatic session discovery settings.", + "saveFailed": "Could not save the automatic session discovery setting." + } + }, "import": { "title": "Import and reuse", "description": "See what content from {{name}} works with OpenBitFun. Import available items directly; other items show their current support status.", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 89d7f7c8d4..23e91ed38f 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -271,6 +271,15 @@ } }, "sessions": { + "removeSource": "从 OpenBitFun 移除", + "removeSourceConfirmTitle": "从 OpenBitFun 移除源会话?", + "removeSourceConfirmMessage": "“{{name}}”将从 OpenBitFun 移除。原应用中的会话和已有续聊分支都会保留,可在生态兼容设置中重新添加。", + "removeSourceExplanation": "源会话仅从 OpenBitFun 移除;原应用中的会话和续聊分支都会保留,可在生态兼容设置中重新添加。", + "removeSelected": "移除所选源会话", + "bulkRemoveSourceConfirmTitle": "移除所选源会话?", + "bulkRemoveSourceConfirmMessage": "将从 OpenBitFun 移除 {{count}} 个源会话。原应用中的会话和已有续聊分支都会保留,可在生态兼容设置中重新添加。", + "removedSelected": "已从列表移除 {{count}} 个会话。", + "removedSourceSucceeded": "已从 OpenBitFun 的 {{workspace}} 中移除“{{name}}”。", "newSession": "新建会话", "acpSessions": "ACP 会话", "dispatchRunningOn": "在 {{target}} 上运行({{state}})", @@ -477,7 +486,12 @@ "deleteAllArchivedFailed": "删除全部已归档会话失败", "archiveAllFailed": "归档会话失败", "bulkArchiveFailed": "批量归档会话失败", - "bulkDeleteFailed": "批量删除会话失败" + "bulkDeleteFailed": "批量删除会话失败", + "sourceAgentFilter": "会话来源", + "allSourceAgents": "全部 Agent", + "sourceAgentLabel": "来自 {{name}} 的会话", + "discoveryPartial": "部分外部会话无法发现,已有会话仍可使用。", + "discoveringExternal": "正在发现外部会话…" }, "scheduledJobs": { "title": "定时任务", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 6ad426989f..fa50f84504 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1,4 +1,20 @@ { + "externalHistory": { + "removed": "源会话已从 OpenBitFun 移除,当前分支保留独立历史。", + "restoreSource": "重新添加源会话", + "sourceMissing": "源会话已不可用,当前分支保留独立历史。", + "openFailed": "无法打开源会话。当前分支可正常使用,请稍后重试。", + "restoreFailed": "无法重新添加源会话。当前分支不受影响,请稍后重试。", + "unavailableWithoutCache": "来源不可用,且没有可用的缓存历史。请恢复来源后重新打开会话。", + "unsupportedSurface": "Peer 模式暂不支持跟随来源会话。请在来源主机上打开,或继续已有的 OpenBitFun 分支。", + "forkBeforeEdit": "请先分叉此来源会话,再编辑或回退历史。", + "followHint": "来自 {{source}} · 发送后在 OpenBitFun 新分支中继续", + "branchHint": "继续自 {{source}}", + "openSource": "打开来源会话", + "unavailable": "来源暂不可用,显示上次缓存", + "incomplete": "来源对话尚未完成,当前显示已读取的历史", + "openBeforeContinue": "请先打开来源会话并等待历史加载完成,再继续对话。" + }, "subagentIdentity": { "names": { "bouncyBean": "豆豆", diff --git a/src/web-ui/src/locales/zh-CN/scenes/ecosystem-compatibility.json b/src/web-ui/src/locales/zh-CN/scenes/ecosystem-compatibility.json index 7886b1a34a..bf5562f86e 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/ecosystem-compatibility.json +++ b/src/web-ui/src/locales/zh-CN/scenes/ecosystem-compatibility.json @@ -68,6 +68,28 @@ "pet": "宠物", "instruction": "指令与规则" }, + "sessions": { + "removed": { + "title": "已移除的源会话", + "description": "将源会话重新添加到会话列表。已有的 OpenBitFun 续聊分支保持独立,源会话在打开时更新历史。", + "empty": "没有已移除的源会话。", + "restore": "重新添加", + "restoreNamed": "重新添加“{{name}}”", + "retry": "重试", + "loadFailed": "无法读取已移除的源会话,请在主机可用后重试。", + "restoreFailed": "无法重新添加此源会话。移除记录已保留,请稍后重试。", + "refreshFailed": "源会话已重新添加,但列表刷新失败。请重新打开工作区查看。", + "unsupportedHost": "当前主机不支持重新添加已移除的源会话,请更新运行会话的主机后重试。", + "unsupportedSurface": "请在会话所属主机管理已移除的源会话,Peer 模式暂不支持此操作。" + }, + "autoDiscovery": { + "label": "自动发现 {{name}} 会话", + "description": "打开工作区时,只建立 {{name}} 会话的轻量索引并显示在列表中。首次打开或续聊时才保存完整历史;未打开的会话仍依赖原始来源。关闭开关保留已有条目和副本。暂不支持 SSH 工作区发现。", + "unsupported": "当前主机不支持会话自动发现,请更新会话所在主机后启用。", + "loadFailed": "无法加载会话自动发现设置。", + "saveFailed": "无法保存会话自动发现设置。" + } + }, "import": { "title": "导入与复用", "description": "查看 {{name}} 可与 OpenBitFun 配合使用的内容。可导入的内容可以直接添加,其余内容会显示当前支持状态。", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 052557fa72..6e067a75d7 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -271,6 +271,15 @@ } }, "sessions": { + "removeSource": "從 OpenBitFun 移除", + "removeSourceConfirmTitle": "從 OpenBitFun 移除來源對話?", + "removeSourceConfirmMessage": "「{{name}}」將從 OpenBitFun 移除。原應用程式中的對話和既有續聊分支都會保留,可在生態相容設定中重新新增。", + "removeSourceExplanation": "來源對話僅從 OpenBitFun 移除;原應用程式中的對話和續聊分支都會保留,可在生態相容設定中重新新增。", + "removeSelected": "移除所選來源對話", + "bulkRemoveSourceConfirmTitle": "移除所選來源對話?", + "bulkRemoveSourceConfirmMessage": "將從 OpenBitFun 移除 {{count}} 個來源對話。原應用程式中的對話和既有續聊分支都會保留,可在生態相容設定中重新新增。", + "removedSelected": "已從清單移除 {{count}} 個對話。", + "removedSourceSucceeded": "已從 OpenBitFun 的 {{workspace}} 中移除「{{name}}」。", "newSession": "新增會話", "acpSessions": "ACP 會話", "dispatchRunningOn": "在 {{target}} 上執行({{state}})", @@ -477,7 +486,12 @@ "deleteAllArchivedFailed": "刪除全部已歸檔會話失敗", "archiveAllFailed": "歸檔會話失敗", "bulkArchiveFailed": "批量歸檔會話失敗", - "bulkDeleteFailed": "批量刪除會話失敗" + "bulkDeleteFailed": "批量刪除會話失敗", + "sourceAgentFilter": "工作階段來源", + "allSourceAgents": "全部 Agent", + "sourceAgentLabel": "來自 {{name}} 的工作階段", + "discoveryPartial": "部分外部工作階段無法探索,既有工作階段仍可使用。", + "discoveringExternal": "正在探索外部工作階段…" }, "scheduledJobs": { "title": "定時任務", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 3fbea6291d..d131acf573 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1,4 +1,20 @@ { + "externalHistory": { + "removed": "來源對話已從 OpenBitFun 移除,目前分支保留獨立歷史。", + "restoreSource": "重新新增來源對話", + "sourceMissing": "來源對話已無法使用,目前分支保留獨立歷史。", + "openFailed": "無法開啟來源對話。目前分支可正常使用,請稍後重試。", + "restoreFailed": "無法重新新增來源對話。目前分支不受影響,請稍後重試。", + "unavailableWithoutCache": "來源無法使用,且沒有可用的快取歷史。請恢復來源後重新開啟對話。", + "unsupportedSurface": "Peer 模式暫不支援跟隨來源會話。請在來源主機上開啟,或繼續已有的 OpenBitFun 分支。", + "forkBeforeEdit": "請先分叉此來源會話,再編輯或回退歷史。", + "followHint": "來自 {{source}} · 傳送後在 OpenBitFun 新分支中繼續", + "branchHint": "繼續自 {{source}}", + "openSource": "開啟來源會話", + "unavailable": "來源暫時無法使用,顯示上次快取", + "incomplete": "來源對話尚未完成,目前顯示已讀取的歷史", + "openBeforeContinue": "請先開啟來源會話並等待歷史載入完成,再繼續對話。" + }, "subagentIdentity": { "names": { "bouncyBean": "豆豆", diff --git a/src/web-ui/src/locales/zh-TW/scenes/ecosystem-compatibility.json b/src/web-ui/src/locales/zh-TW/scenes/ecosystem-compatibility.json index 42881a3a03..968e508fff 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/ecosystem-compatibility.json +++ b/src/web-ui/src/locales/zh-TW/scenes/ecosystem-compatibility.json @@ -68,6 +68,28 @@ "pet": "寵物", "instruction": "指令與規則" }, + "sessions": { + "removed": { + "title": "已移除的來源對話", + "description": "將來源對話重新新增至對話清單。既有的 OpenBitFun 續聊分支保持獨立,來源對話在開啟時更新歷史。", + "empty": "沒有已移除的來源對話。", + "restore": "重新新增", + "restoreNamed": "重新新增「{{name}}」", + "retry": "重試", + "loadFailed": "無法讀取已移除的來源對話,請在主機可用後重試。", + "restoreFailed": "無法重新新增此來源對話。移除記錄已保留,請稍後重試。", + "refreshFailed": "來源對話已重新新增,但清單重新整理失敗。請重新開啟工作區查看。", + "unsupportedHost": "目前主機不支援重新新增已移除的來源對話,請更新執行對話的主機後重試。", + "unsupportedSurface": "請在對話所屬主機管理已移除的來源對話,Peer 模式暫不支援此操作。" + }, + "autoDiscovery": { + "label": "自動探索 {{name}} 工作階段", + "description": "開啟工作區時,只建立 {{name}} 工作階段的輕量索引並顯示於清單。首次開啟或繼續對話時才儲存完整歷史;尚未開啟的工作階段仍依賴原始來源。關閉開關保留既有項目與副本。暫不支援 SSH 工作區探索。", + "unsupported": "目前主機不支援工作階段自動探索,請更新工作階段所在主機後啟用。", + "loadFailed": "無法載入工作階段自動探索設定。", + "saveFailed": "無法儲存工作階段自動探索設定。" + } + }, "import": { "title": "匯入與複用", "description": "查看 {{name}} 可與 OpenBitFun 搭配使用的內容。可匯入的內容可以直接加入,其餘內容會顯示目前支援狀態。", diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index 076297e8b1..e1d6f2d812 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -26,6 +26,20 @@ export interface SessionRelationship { } export interface SessionCustomMetadata extends Record { + importedFrom?: string; + externalHistoryMode?: 'follow'; + externalHistoryPending?: boolean; + externalContentVersion?: string; + externalSourceStatus?: 'available' | 'unavailable'; + externalSourceIncomplete?: boolean; + externalObservedFingerprint?: string; + externalMaterializedObservation?: string; + externalBranchOf?: { + sessionId: string; + ecosystemId: string; + sourceSessionId: string; + contentVersion: string; + }; kind?: SessionKind; parentSessionId?: string | null; parentRequestId?: string | null;