From 79d6639074c0f74baa75186456557fbad9e0bd51 Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 4 Aug 2026 00:41:17 +0800 Subject: [PATCH] feat(extensions): activate external primary agent profiles --- .../agent-runtime-deployment-design.md | 7 +- .../external-ai-work-sources-design.md | 25 +- .../opencode-config-assets-adapter-design.md | 32 +- .../opencode-extension-compatibility.md | 2 +- .../rules/source/forbidden-rules.mjs | 2 +- .../rules/source/public-api-rules.mjs | 1 + src/apps/cli/src/agent/runtime_client.rs | 128 ++++- src/apps/cli/src/main.rs | 17 + src/apps/cli/src/management.rs | 36 +- src/apps/cli/src/modes/chat.rs | 2 +- src/apps/cli/src/modes/chat/run.rs | 28 +- src/apps/cli/src/modes/chat/selection.rs | 17 +- src/apps/cli/src/modes/chat/tests.rs | 24 +- .../peer_host/commands/external_sources.rs | 2 +- src/apps/cli/src/peer_host/commands/mod.rs | 3 +- .../cli/src/peer_host/commands/session.rs | 54 ++- src/apps/cli/src/shared_runtime.rs | 63 ++- src/apps/cli/src/ui/startup.rs | 116 ++++- src/apps/desktop/src/api/agentic_api.rs | 180 ++++++- .../src/api/remote_workspace_policy.rs | 1 + src/apps/desktop/src/lib.rs | 1 + .../adapters/agent-runtime-ipc/AGENTS-CN.md | 2 +- .../adapters/agent-runtime-ipc/AGENTS.md | 4 +- .../adapters/agent-runtime-ipc/src/lib.rs | 5 +- .../agent-runtime-ipc/src/operation.rs | 42 +- .../agent-runtime-ipc/src/protocol.rs | 2 +- .../src/tests/protocol_contracts.rs | 69 ++- .../src/tests/shared_controller.rs | 57 ++- .../claude-code-adapter/src/agent_source.rs | 5 +- .../tests/subagent_source.rs | 7 +- .../opencode-adapter/src/agent_source.rs | 10 +- .../tests/opencode_subagent_adapter.rs | 48 +- .../agentic/agents/definitions/external.rs | 11 +- .../assembly/core/src/agentic/agents/mod.rs | 7 +- .../src/agentic/agents/registry/external.rs | 196 +++++++- .../core/src/agentic/agents/registry/mod.rs | 5 +- .../core/src/agentic/agents/registry/query.rs | 36 +- .../core/src/agentic/agents/registry/tests.rs | 276 +++++++++++ .../src/agentic/coordination/coordinator.rs | 443 ++++++++++++++++-- .../src/agentic/coordination/scheduler.rs | 50 +- .../assembly/core/src/agentic/core/mod.rs | 4 +- .../assembly/core/src/agentic/core/session.rs | 4 +- .../core/src/agentic/permission_policy.rs | 33 +- .../src/agentic/session/session_manager.rs | 226 ++++++++- .../tools/implementations/task/execution.rs | 1 + .../assembly/core/src/agentic/workspace.rs | 54 ++- .../assembly/core/src/external_sources.rs | 35 +- .../assembly/core/src/external_subagents.rs | 150 +++--- src/crates/contracts/core-types/src/lib.rs | 7 +- .../contracts/core-types/src/session.rs | 13 + .../product-domains/src/external_subagents.rs | 15 +- src/crates/contracts/runtime-ports/src/lib.rs | 6 + .../execution/agent-runtime/src/session.rs | 37 +- .../src/flow_chat/components/ChatInput.tsx | 217 ++++----- .../flow_chat/components/ModelSelector.tsx | 20 +- .../components/ModelSelectorExternal.test.tsx | 31 ++ .../hooks/useSessionModeSelection.test.tsx | 188 ++++++++ .../hooks/useSessionModeSelection.ts | 126 +++++ .../hooks/useWorkspaceModeCatalog.test.tsx | 135 ++++++ .../hooks/useWorkspaceModeCatalog.ts | 65 +++ .../src/flow_chat/reducers/modeReducer.ts | 2 + .../flow-chat-manager/SessionModule.test.ts | 14 +- .../flow-chat-manager/SessionModule.ts | 8 +- .../local/LocalSessionDriver.ts | 29 +- .../src/flow_chat/utils/chatInputMode.test.ts | 23 +- .../src/flow_chat/utils/chatInputMode.ts | 14 +- .../src/flow_chat/utils/modelResolution.ts | 18 - .../api/service-api/AgentAPI.test.ts | 36 ++ .../api/service-api/AgentAPI.ts | 28 +- .../service-api/ExternalSourcesAPI.test.ts | 12 +- .../api/service-api/ExternalSourcesAPI.ts | 39 +- .../components/ExternalSourcesConfig.test.tsx | 2 +- .../components/ExternalSourcesConfig.tsx | 2 +- src/web-ui/src/locales/en-US/flow-chat.json | 1 + .../en-US/settings/external-sources.json | 6 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + .../zh-CN/settings/external-sources.json | 6 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + .../zh-TW/settings/external-sources.json | 6 +- 79 files changed, 3115 insertions(+), 516 deletions(-) create mode 100644 src/web-ui/src/flow_chat/hooks/useSessionModeSelection.test.tsx create mode 100644 src/web-ui/src/flow_chat/hooks/useSessionModeSelection.ts create mode 100644 src/web-ui/src/flow_chat/hooks/useWorkspaceModeCatalog.test.tsx create mode 100644 src/web-ui/src/flow_chat/hooks/useWorkspaceModeCatalog.ts diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index c03cf674fd..ea34089258 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -207,11 +207,11 @@ sequenceDiagram end ``` -当前私有协议(v15)只覆盖 TUI 已有用户旅程需要的窄操作: +当前私有协议(v16)只覆盖 TUI 已有用户旅程需要的窄操作: | 已支持 | 明确不支持 | |---|---| -| Health、Session list/create、原子 restore(含 transcript 与 pending Permission)、删除未被控制的空闲 Session、当前 Session fork(含 transcript)、rename、Agent mode/model update、声明式上下文 reload、根 Session lineage 查询与后代 transcript 读取 | Session archive、跨 workspace attach、transcript 分页、模型目录/默认值和 Agent/Subagent 管理 | +| Health、只读 workspace-scoped main Agent 摘要、Session list/create、原子 restore(含 transcript 与 pending Permission)、删除未被控制的空闲 Session、当前 Session fork(含 transcript)、rename、Agent mode/model update、声明式上下文 reload、根 Session lineage 查询与后代 transcript 读取 | Session archive、跨 workspace attach、transcript 分页、模型目录/默认值和完整 Agent/Subagent 管理 | | Turn submit/cancel、当前 Session 手动 context compaction、lineage 成员校验后的单个后代执行子树取消 | replay、cursor、resume event stream、独立的根级批量后代取消 API | | pending/respond Permission、submit UserInput answers、只读 workspace diff | observer、通用 controller transfer、多 Session multiplex | | 连接断开清理、Session-filtered events | detach/observer/通用 controller transfer、SDK callbacks、GUI/Remote/Peer/ACP/Headless wire | @@ -233,9 +233,10 @@ sequenceDiagram - v12 增加用户显式 Shell Turn;v13 增加活动 Turn steering。两者都复用 Agent Runtime 的原有准入、Tool、权限、持久化和取消 owner,不在 IPC 内复制执行状态机。 - v14 增加三个 current-root-controller 限定的 lineage operation:查询 Runtime 归一化后的扁平 lineage、读取已验证后代的权威 transcript,以及取消指定后代的活动执行子树。查询和读取可在根 Turn 活动时执行;取消复用现有 Session abort 语义,但不切换 controller,也不引入 observer、detach、分页或通用 Session RPC。 - v15 为后代 transcript 读取增加 `required_settled_turn_ids` 一致性前置条件:Runtime 必须确认这些 Turn 已由 owner 持久化为终态,否则返回 `outcome_unknown`,由 TUI 在同一绝对期限内退避重试;TUI 只保留事件投影和该读屏障,不合并或重写权威 transcript。后代取消同时携带用户实际看到的 `expected_active_turn_id`,并在 owner 锁内拒绝已经切换的 Turn,避免迟到操作取消后续执行。lineage 查询和 transcript 读取是每连接至多一个的可抢占推测读取;更新的请求会取消旧读取,使后代取消和 Session 切换不会排在慢 transcript I/O 之后。该行为不放宽 controller 校验,不引入 observer 或通用多路复用。 +- v16 增加只读、workspace-scoped main Agent 摘要,用于 Shared TUI 与 Runtime host 的 selector 投影一致。启动页以 Runtime 启动工作区查询且不取得 Session lease;已有 Session 由 Runtime owner 解析其执行工作区并要求当前 controller。响应只包含逻辑 ID、描述、可选固定 model ID 与 ecosystem-neutral 的 external-source 分类;发现、审批、冲突消解、generation 与执行仍由既有 Agent Registry 和 external-source owner 负责,不经 IPC 暴露安装、变更、激活、Subagent 管理或 runtime lifecycle API。 - 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore/fork 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。fork 只接受当前 controller 的空闲 Session;无选中 Turn 时复制到最新持久化 Turn,指定 `before_turn_id` 时只复制该 Turn 之前的历史。活动 Turn 期间不能切换或 fork Session,也不能修改其名称、Agent mode 或 model;删除只作用于非当前且未被任何连接控制的 Session。 - Submit 与手动 context compaction 都使用调用方已有的 `turn_id` 标识不确定结果;若操作超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。手动 compaction 要求当前 controller 且 Session 空闲,由 Core 通过与普通对话 Turn 共用的原子准入路径创建一个可审计 maintenance Turn,并在取得所有权后读取压缩上下文:planning 阶段允许取消,atomic commit 开始后忽略晚到取消并保持 Processing 直至终态持久化完成。maintenance Turn 保留在权威 transcript 中但不进入模型上下文,live/restored payload 使用同一 compression ID 和 `applied` 事实;commit 后的持久化故障发布明确失败终态而不是遗留 Processing。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 -- Session delete/rename 和 Agent mode/model update 复用既有 Runtime 端口和校验,Runtime 对最终结果保持权威并拒绝无效目标。它们都是有副作用操作;发送前编码或 frame 上限失败表示请求未执行,连接仍可使用。rename 写入失败时恢复旧 metadata:确认恢复后返回明确失败,无法确认时返回 `outcome_unknown`。Shared Client 在请求写入后响应超时或丢失连接时也返回 `outcome_unknown` 并断开连接。两种情况都不自动重试:rename 由用户恢复 Session 并核对当前值;delete 由用户重新打开 `/sessions` 核对目标是否仍存在。模式与模型目录仍是同版本第一方产品事实,不加入 IPC。 +- Session delete/rename 和 Agent mode/model update 复用既有 Runtime 端口和校验,Runtime 对最终结果保持权威并拒绝无效目标。它们都是有副作用操作;发送前编码或 frame 上限失败表示请求未执行,连接仍可使用。rename 写入失败时恢复旧 metadata:确认恢复后返回明确失败,无法确认时返回 `outcome_unknown`。Shared Client 在请求写入后响应超时或丢失连接时也返回 `outcome_unknown` 并断开连接。两种情况都不自动重试:rename 由用户恢复 Session 并核对当前值;delete 由用户重新打开 `/sessions` 核对目标是否仍存在。模型目录以及完整 Agent/Subagent 管理仍是同版本第一方产品事实,不加入 IPC;v16 的 main Agent 摘要只是 host-owned selector 所需的最小只读投影。 - 声明式上下文 reload 只失效当前 Session 的 instructions 缓存,并按目标复用 Skill Registry 刷新;它可在活动 Turn 中执行但不改写该 Turn,generation 保护保证下一条消息重建上下文。它不引入 watcher、热替换或第二套 Runtime owner。 - Shared TUI 的模型选择器复用 Client 已有的只读产品配置来显示同版本模型目录;它只把选中的 model ID 通过 `update current Session model` 交给 Runtime。Client 不持有 Session 写入权,也不通过 IPC 管理模型目录或默认值。 - Agent 事件流 lag/closed 后 fail closed;Permission lag 先从 Runtime 权威 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。路由到父 Session 的嵌套 Permission 与 AskUserQuestion 复用现有 TUI 交互,不新增第二套 UI 状态。 diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index fcca9ef028..e126d2f1df 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -13,14 +13,16 @@ Desktop、交互式 TUI、Peer Host 和只读 Server 只显示宿主所需状态 适配器已接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突,交互式 TUI(ChatMode)可列出并执行 Prompt Command;静态文件和经审阅的本地 shell 输出由共享归属模块完成装配。第二条端到端能力已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 预览、来源/能力确认和同名冲突选择后进入现有 Tool Runtime;Desktop 与交互式 TUI(ChatMode)使用同一决策状态。第三条纵向 -切片已把 OpenCode 全局/项目 Subagent 的安全子集通过独立 provider 契约接入现有 Subagent 归属模块:首次启用与 -同名冲突使用非阻塞决策,fresh 调用持续使用启动时选定的版本,更新和撤下不会静默切换到同名实现。第四条端到端能力 +切片已把 OpenCode 全局/项目 Agent 的安全子集通过独立 provider 契约接入现有 Agent 归属模块:首次启用与 +同名冲突使用非阻塞决策,`primary|subagent|all` 从同一 workspace route 投影到主选择器和 Task,调用持续使用启动时选定的 +版本,更新和撤下不会静默切换到同名实现。第四条端到端能力 已把 OpenCode 用户/项目 MCP 的 local stdio 与 HTTPS remote 安全子集接入现有 MCP 归属模块,沿用显式审批、冲突、 工作区隔离和失败反馈;现有 Skill 加载模块另行展示来源、用户/项目范围和固定优先级产生的覆盖结果,不并入上述 可执行来源选择规则。第五条端到端能力在不增加新的 Rust Runtime 进程的前提下接入 Claude Code 的 legacy Command、Subagent、 MCP 安全子集,以及 Codex Subagent、MCP 安全子集;三种生态使用同一个来源管理模块,并共享审批、冲突、刷新和故障隔离规则, 但各自在 sibling adapter 内保留原生来源与覆盖语义。完整 TypeScript/Bun、包依赖、package plugin 执行、 -Codex/Claude Code 运行时适配、primary agent 替换和外部 Subagent 续接仍属于后续阶段,不能因来源被识别就宣称已经可用。OpenCode、Claude Code 与 Codex 的本地 Hook 脱敏目录 +Codex/Claude Code 运行时适配和外部 Subagent 续接仍属于后续阶段;Claude Code Agent 定义可按同一静态安全子集进入主选择器, +Codex role 仍仅作为 Subagent,不能因来源被识别就宣称宿主运行时兼容。OpenCode、Claude Code 与 Codex 的本地 Hook 脱敏目录 已作为独立只读切片接入;在此之上,Claude Code 与 Codex 的同步 command 子集可经精确命令审阅复制为 BitFun 管理的 原生 Hook 层,仍由唯一 `AgentHookEngine` 执行。OpenCode handler、非 command/异步 handler 和未审阅声明仍不可执行。 独立的 MCP C0a 快照导入复用上述来源与现有 MCP 配置 owner:Desktop 和根 CLI 可预览 OpenCode、Claude Code @@ -244,16 +246,16 @@ OpenCode Subagent 属于 L2:adapter 只读取声明,不执行外部代码; 内容摘要”的不可变绑定;`Inherit` 则只在 fresh 子任务创建时解析一次父会话已经选择的模型。provider、模型名、endpoint 或其他影响运行身份的配置变化都会生成新的审批决策。运行中的调用继续使用启动时租约固定的版本,不能静默回退。 -#### 4.2.1 外部 Subagent 模型引用与显式绑定 +#### 4.2.1 外部 Agent 模型引用与显式绑定 当前生产链路已经按本节契约接通 OpenCode、Claude Code 与 Codex adapter、共享来源快照、现有偏好 owner、Desktop、 -Peer Host、Web 设置页、交互式 TUI 和 fresh child session 创建路径。本节解决的是“外部来源声明的模型如何绑定到用户 +Peer Host、Web 设置页、交互式 TUI、主 Session 和 fresh child session 创建路径。本节解决的是“外部来源声明的模型如何绑定到用户 实际配置”,而不是维护 Claude、GPT、GLM、DeepSeek 等厂商或 型号目录。生产代码把外部模型名视为不透明引用,不按名称片段推断质量、速度、推理能力、成本或等价型号,也不在 Product Domain、Assembly 或 UI 中维护跨厂商替换表。生态 adapter 只解释自身已验证的语法,并提交以下来源无关的模型请求: -- `Default`:来源没有指定模型,使用 BitFun 已有 Subagent 默认选择; -- `Inherit`:仅当来源规范明确声明继承父会话模型时使用,不能由通用模块根据字符串猜测; +- `Default`:来源没有指定模型,调用时继承当前 Session 已选择的模型,不查询同名 BitFun Agent 的默认项; +- `Inherit`:来源规范显式声明继承当前 Session 模型;运行语义与 `Default` 相同,但保留不同的来源意图,不能由通用模块根据字符串猜测; - `Reference`:保留 adapter 已解析的可选 provider 提示和原始模型引用,模型名保持不透明。 来源还可以在模型请求之外声明一个可选 profile 意图,但只支持两个有明确消费点的形态: @@ -285,11 +287,12 @@ profile 只是选择现有配置时的来源意图,不是新的模型配置 ow 规范化外部模型引用、适用范围和执行域;同一决策身份影响的当前候选在管理界面聚合展示和一次选择,避免一个 Agent 包中 几十个相同引用逐项询问。来源引用变化会产生新的决策身份,不能继承旧绑定。 -`Inherit` 不在发现或审批阶段伪装成某个固定模型。外部 Subagent 注册将继承意图交给现有 Subagent 模型选择 owner, +`Default`(未声明模型)与 `Inherit` 都不在发现或审批阶段伪装成某个固定模型。外部 Agent 注册将继承意图交给现有模型选择 owner, 在调用时使用当前父 Session 已明确选择的模型;审批 envelope 记录的是“继承父模型”这一行为,而不是某个偶然的父模型 ID。 调用开始时只解析一次父模型,随后创建的 fresh 子 Session 仍保持 `ApprovedImmutable`,不会跟随父 Session 的后续模型切换; -调用时父模型不可用则返回明确失败,不回退到默认模型。`Default`、`primary`、`fast` 和具体模型绑定仍在激活前解析为 -具体模型配置,并携带运行配置指纹。该路径扩展现有外部 generation lease 的模型绑定形态,不建立第二套 Subagent Runtime。 +调用时父模型不可用则返回明确失败,不回退到默认模型。只有显式模型引用及其用户绑定在激活前解析为具体模型配置并携带 +运行配置指纹;主 Agent 的固定模型只在创建无显式模型的新 Session 时作为默认值,随后仍可由用户修改。该路径扩展现有外部 +generation lease 的模型绑定形态,不建立第二套 Agent Runtime。 Desktop、交互式 TUI 以及未来通过 Host 能力访问该状态的界面必须同时展示:来源请求、实际绑定、绑定方式和受影响候选数。 例如“来源请求 `sonnet`;当前工作区由用户绑定到 Primary(实际为已配置模型 X);影响 71 个 Agent”。用户可以选择其他 @@ -350,7 +353,7 @@ Plugin Host Runtime、LSP,以及通用动态模型路由器。现有 capabilit |---|---|---|---|---| | Rules / Instructions | 用户级 `AGENTS.md`/Claude fallback 与本地 `instructions` 文件、glob;项目级本地文件、glob | 用户与项目 `CLAUDE.md`、项目导入及 `.claude/rules/**/*.md`;带 `paths` 的规则延迟生效 | 用户与项目 `AGENTS.md` | 无条件来源按既有 user → workspace 顺序进入启动上下文;Claude path-scoped rule 仅在 `Read` 成功返回且工作区相对路径命中后追加到当前会话历史。条件内容在压缩时丢弃,之后需再次命中读取才恢复;不增加 watcher、UI、Plugin Host Runtime 或第二套 Rules owner。Remote 只发现远端工作区来源,不回退控制端用户目录。 | | Prompt Command | JSON/JSONC、Markdown 的 prompt、本地文本文件与经审阅 shell 上下文子集 | legacy `commands/**/*.md` 的同一子集;Skills 仍由 Skill 归属模块处理 | 没有稳定、独立于 Skills 的声明式 Command 来源,因此不伪造 provider | `$ARGUMENTS`/位置参数及模板内 workspace 相对 UTF-8 `@file` 可展开;`!shell` 在展示精确计划并重新校验后仅把 stdout 加入 Prompt,参数相关计划不可记住。Claude `allowed-tools` 只校验宿主格式,不授予预批准;动态/绝对/越界文件、指定 Agent/模型等整体受限;Remote 不回退本机执行。 | -| Subagent | 用户/项目声明的安全子集 | 用户/项目 `agents/**/*.md` 的安全子集 | 用户/项目 `[agents]`、角色文件与安全配置层子集 | prompt、描述、`Default`/真实继承/不透明模型引用、OpenCode 不透明 variant、Claude/Codex reasoning effort 和可表达工具请求进入既有归属模块;无 profile 的模型引用可唯一精确匹配,variant/effort profile 必须由用户绑定到现有配置后才可激活。来源请求、profile、实际模型和解析方式在 Web/TUI 可见。权限、私有 MCP/Hook、reasoning summary/verbosity、采样、并发等没有对应实现的字段仍会阻止或降级。 | +| Agent | 用户/项目声明的安全子集与 `primary/subagent/all` role | 用户/项目 `agents/**/*.md` 的主 Agent/Subagent 共用安全子集 | 用户/项目 `[agents]`、角色文件与安全配置层的 Subagent 子集 | prompt、描述、继承/不透明模型引用、OpenCode 不透明 variant、Claude/Codex reasoning effort 和可表达工具请求进入既有归属模块;OpenCode/Claude 按 role 投影主选择器或 Task,Codex 保持 Subagent。无 profile 的显式模型引用可唯一精确匹配,variant/effort profile 必须由用户绑定到现有配置后才可激活。来源请求、profile、实际模型和解析方式在 Web/TUI 可见。权限、私有 MCP/Hook、reasoning summary/verbosity、采样、并发等没有对应实现的字段仍会阻止或降级。 | | MCP | 用户/显式目录/项目配置的安全子集 | user/project/local 原生层的安全子集 | 用户与项目 `config.toml` 原生层的安全子集 | 支持可表达的 stdio 与 HTTPS Streamable HTTP;发现不启动 Server,首次激活继续经 BitFun MCP 审批。OAuth、remote executor、per-tool policy 等不完整语义明确降级。 | | Standalone Tool | 已有单文件 JavaScript 子集 | 无稳定的 runtime-free standalone Tool 来源 | 无稳定的 runtime-free standalone Tool 来源 | TypeScript、package/plugin Tool 与动态工具注册依赖独立 Plugin Host,不在声明式 adapter 中猜测。 | | Skill | 由现有 Skill 加载模块发现 `.opencode` 标准根及 OpenCode 本地配置根 | 由现有 Skill 加载模块发现 `.claude` 标准根;目录名是调用身份,描述可回退正文首段,`when_to_use` 合入索引,声明参数可做纯文本命名展开 | 由现有 Skill 加载模块发现 `.codex`、`.agents` 标准根;`.codex` 缺少 `name` 时回退目录名 | OpenCode V1 `skills.paths`/当前本地字符串数组只经 `bitfun-core/external_sources` 组合边界投影根目录,递归、加载、覆盖、模式开关与执行仍由同一个 Skill 模块负责;URL 不加载。 | diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index 209a16a6ae..7aad29f849 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -206,7 +206,7 @@ OpenCode adapter 在来源发现、解析和审批前不 import module、不读 | 资产 | OpenCode 输入 | BitFun 归属模块 / 适配方式 | 默认行为 | 降级条件 | |---|---|---|---|---| | Rules / Instructions | 项目/全局 `AGENTS.md`、Claude fallback、`instructions` glob、本地文件、远程 URL | 各生态 adapter 保留原生用户来源语义;Workspace Instructions owner 解析项目来源;Product Assembly 有序合成 | 当前支持 OpenCode 用户 `AGENTS.md`/Claude fallback、三份全局配置的最终本地 `instructions`,以及既有项目根与 `.opencode` 本地精确文件/glob;不获取远程 URL | 单个用户生态的无效配置、glob 或文件 I/O 失败会隔离该生态,保留其他用户生态与项目来源,并使本次构建不可缓存;项目配置继续按项目解析器的逐项降级语义处理。 | -| Agents / Modes | 当前生产 V1 `agent/prompt/disable/permission`、Core V2 `agents/system/disabled/permissions` 输入形状,以及 Markdown、description、mode、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、options、hidden、color | Agent 归属模块创建兼容定义和使用范围视图;OpenCode adapter 只翻译来源语义 | 当前支持 Subagent 安全子集、Agent-local 权限约束和不透明 `variant` profile;V1 是生产兼容主路径,Core V2 字段只按已验证安全子集解析;variant 不映射为 reasoning 或请求 options,需显式绑定现有模型配置;首次按行为、来源、模型/profile、工具与权限范围确认,fresh single-run 调用 | primary/mode、options、采样、steps 与续接保持诊断或阻断;root ambient 权限和 V1 嵌套 resource map 尚不激活,不影响其他 Agent。 | +| Agents / Modes | 当前生产 V1 `agent/prompt/disable/permission`、Core V2 `agents/system/disabled/permissions` 输入形状,以及 Markdown、description、mode、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、options、hidden、color | Agent 归属模块创建兼容定义和使用范围视图;OpenCode adapter 只翻译来源语义 | 当前支持静态 Agent 安全子集、`primary/subagent/all` role 投影、Agent-local 权限约束和不透明 `variant` profile;V1 是生产兼容主路径,Core V2 字段只按已验证安全子集解析;variant 不映射为 reasoning 或请求 options,需显式绑定现有模型配置;首次按行为、来源、模型/profile、工具与权限范围确认,主 Turn 与 fresh Task 共用 generation lease | legacy mode、options、采样、steps、Task target 过滤与续接保持诊断或阻断;root ambient 权限和 V1 嵌套 resource map 尚不激活,不影响其他 Agent。 | | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | OpenCode adapter 只由 `bitfun-core/external_sources` 组合并投影有序本地配置根;Skill 归属模块负责有界递归、解析、覆盖与按需加载 | 标准根及 V1 `skills.paths`/当前本地字符串数组可用;项目配置限项目根,用户配置限项目根或用户目录;配置根最多 64 个、每根 512 个 Skill、单文件 256 KiB、可选策略 64 KiB,实际加载再次执行有界非链接读取;配置根在同 scope 覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源 | URL、下载/缓存、脚本与外部依赖不加载;无效根不影响标准 Skill。 | | References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | OpenCode adapter 输出来源无关的 Reference provider snapshot;Product Assembly 生命周期协调器与 BitFun 原生关联目录合成唯一有效引用目录;关联目录视图和既有目录选择器消费 | 当前支持本地声明路径、description/hidden、异步刷新和 `@alias` 展示;原生关联目录始终在 OpenCode 引用之前,外部引用只读、不自动进入 Prompt 且不改变权限 | Git 引用、Remote 发现和下载/缓存不实现;无效高优先级 entry 阻断同 alias 的旧值并给出诊断,不回退到更宽松来源。 | | Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;adapter 提取静态文件引用与 shell 计划,Product Assembly 负责审批指纹和装配,Terminal owner 负责进程执行 | prompt 与静态 workspace 相对 UTF-8 `@file` 可发送;`!shell` 展示精确命令、工作目录与绝对 shell 路径,经重新校验后以不加载 profile 的隔离式 argv 执行,并仅把 stdout 按模板顺序加入 Prompt。为保持 OpenCode 语义,正常退出后的非零退出码仍使用 stdout。静态计划可记住,参数相关计划仅可单次运行;显式 agent 加缺省/true subtask 可走 approved fresh Subagent,其余 agent/model/variant/subtask 组合以及 shell 与委派的组合整体受限 | 任一文件读取、进程启动、超时或超限失败时不发送部分 Prompt;进程副作用不可回滚。最多 8 文件、单文件 64 KiB、文件总量 128 KiB;最多 8 条 shell 指令、单条 64 KiB、总计 128 KiB、每条 stdout 256 Ki 字符、30 秒;最终命令 1 MiB。安全模式禁用,Remote 不回退到本机。 | @@ -242,8 +242,8 @@ watcher;用户通过统一的 `/reload instructions`(或默认 `/reload`) 兼容定义进入现有 Agent 归属模块,而不是新建 OpenCode Agent Runtime。当前已实现范围按是否能保持行为等价划分: -- 可等价映射并激活:名称、description、生产 V1 `prompt/disable` 与 Core V2 `system/disabled` 安全子集、`subagent|all`、隐藏状态、 - 可精确解析的 model,以及能映射到当前有效 Tool route 的明确工具选择;缺省工具使用 BitFun 保守 Subagent +- 可等价映射并激活:名称、description、生产 V1 `prompt/disable` 与 Core V2 `system/disabled` 安全子集、`primary|subagent|all`、隐藏状态、 + 可精确解析的 model,以及能映射到当前有效 Tool route 的明确工具选择;缺省工具使用 BitFun 保守 Agent 默认集并展示在确认摘要中。 - Agent-local 权限:Core V2 有序 `permissions: [{ action, resource, effect }]` 保留顺序;生产 V1 扁平精确 action map `permission: { action: allow|ask|deny }` 转换为 `resource="*"`,并把 `write/edit/patch/apply_patch` 归一为 @@ -255,10 +255,11 @@ watcher;用户通过统一的 `/reload instructions`(或默认 `/reload`) resource 保留原始命令文本。若 action pattern 同时跨越路径与非路径工具、workspace/home 坐标不可得,或前导 wildcard 可能同时匹配 OpenCode 的相对 workspace 与绝对 external resource,则阻断 Agent,不以“未命中即 Allow”继续。 - action pattern 保持 OpenCode 的平台大小写语义:Windows 导入时归一为小写 BitFun action,其他平台保持大小写 - 敏感。外部 Agent 当前不开放 `Task`,因为现有子委派 ceiling 尚不携带该 Agent 的外部约束;不能让显式工具名绕过此边界。 + 敏感。主 Agent 委派时会把外部定义的 ask/deny 约束并入现有 parent runtime ceiling;外部 Agent 仍不开放 `Task`, + 因为 OpenCode 的 task target 过滤和委派权限尚未等价映射,不能把工具名存在误当成完整委派语义。 - 仅当 Agent 显式声明 `model` 时,`variant` 才保留为不透明模型 profile;未声明模型的 variant 与 OpenCode 一样不生效。 保留的 variant 不能映射为 reasoning effort 或任意请求 options,并要求用户显式绑定到现有模型配置。 - 可识别但不激活的仍包括 `primary`/legacy mode、root ambient permission、V1 action pattern 或嵌套 resource pattern、options、 + 可识别但不激活的仍包括 legacy mode、root ambient permission、V1 action pattern 或嵌套 resource pattern、options、 temperature/top_p、steps/deprecated maxSteps,以及不能精确解析的模型或工具。当前不能把这些字段静默忽略后宣称兼容。 - 展示映射:color 等只影响来源 Surface,不进入运行时权威事实。 - 未知字段:进入来源限定诊断,不作为任意数据传给 core;后续版本支持时由 OpenCode adapter 更新解释。 @@ -278,11 +279,11 @@ Core 只消费来源无关候选,按当前 变化后不静默回退。 OpenCode adapter 负责把 `provider/model` 语法解析成来源无关的 provider 提示与模型名;Core 不解释 OpenCode 字符串 -格式。进入审批前,Subagent 归属模块必须把该请求或 BitFun 的固定 Subagent 默认项解析成唯一、已启用的具体模型,并把 -具体模型的配置 ID 与运行配置内容摘要写入决策和版本内容摘要。`inherit`、`primary`、`fast`、`auto`、`default` 在已经固定的 -绑定中只可能是普通配置 ID,不得再次解释成继承或默认选择;未配置的默认项、歧义匹配或已停用模型保持不可用并 -给出诊断,不能用运行时回退绕过审批。同一 ID 下的 provider、模型名、endpoint 或其他运行身份变化也会异步重建后续 -调用使用的版本并要求重新确认。运行中的旧调用继续使用其启动时的绑定;执行时若内容摘要已不匹配则拒绝执行,不静默改变模型。 +格式。未声明 model 的 `Default` 与显式 `inherit` 都保留为继承意图:主 Agent 使用当前 Session 模型,Subagent 在调用开始时 +继承父 Session 模型,不根据同名 BitFun Agent 猜测默认值。显式引用必须在审批前解析为唯一、已启用的具体模型,并把 +配置 ID 与运行配置内容摘要写入决策和版本内容摘要;歧义匹配或已停用模型保持不可用。同一 ID 下的 provider、模型名、 +endpoint 或其他运行身份变化会异步重建后续调用使用的版本并要求重新确认。运行中的旧调用继续使用其启动时的绑定; +执行时若内容摘要已不匹配则拒绝执行,不静默改变模型。 通用诊断携带 `Source / Command / Tool / Subagent` 资源类型,产品入口只按该类型路由;`opencode.*` 诊断码仅用于技术详情, 不能成为 Core、GUI 或 TUI 的业务分支条件。能力 provider 契约限制来源、定义、provenance 和诊断集合,校验诊断码、 @@ -290,9 +291,14 @@ OpenCode adapter 负责把 `provider/model` 语法解析成来源无关的 provi 位置及诊断中的已知路径统一转换为 `/…`、`~/…`、`/…` 等安全标签,`.opencode` 路径识别仍只属于 本 adapter。 -Subagent 归属模块仍通过现有 Task 执行链完成调用。新的调用在执行前取得现有运行租约,固定 -`runtime_agent_key` 与模型绑定,并由前台或后台任务持有到结束;当前不支持外部 session follow-up、primary agent 替换、 -OpenCode 会话内核、完整 permission DSL 或 package plugin。Desktop/TUI 摘要不包含 prompt +Agent Registry 按 role 从同一个 workspace route 和 generation registry 投影主 Agent 选择器与 Task 列表。主 Session、Turn +和界面只保存稳定 logical id;Session 额外持久化 provider-neutral 的 `Local|External` route owner,避免重启后缺失的外部定义被 +同名本地模式静默接管,但不持久化 provider、generation 或 `runtime_agent_key`。每个主 Turn 或 fresh Task 在执行前取得运行租约, +固定 `runtime_agent_key`、prompt、工具、权限与模型绑定直到结束,更新和撤下只影响下一次调用;外部 route 不可用时保持失败关闭, +只有用户显式选择当前冲突候选才会改变 route owner。显式模型只作为新 Session 无显式选择时的 profile 默认值,后续用户模型 +选择仍归 Session owner。Shared TUI 通过既有私有 Agent Runtime IPC 读取执行宿主的同一主 Agent 投影视图,协议只携带选择器所需 +摘要,不携带来源状态或 prompt;Embedded TUI、Desktop 和 Peer Host 仍消费同一个 Agent Registry owner。当前不支持外部 session +follow-up、OpenCode 会话内核、完整 permission DSL 或 package plugin。Desktop/TUI 摘要不包含 prompt 正文,静态 system prompt 也不因该适配而改写。来源 `description` 只进入审批和管理界面;已批准 Agent 进入现有 `` 动态视图时使用 BitFun 生成的稳定摘要,避免只改目录文案就绕过行为重批并改变模型上下文。 diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index 0c66014946..5f17df5546 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -107,7 +107,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 主要本地来源已实现 | 可主要适配 | OC-R1 | 已支持全局/项目 JSON/JSONC、`XDG_CONFIG_HOME`、`OPENCODE_CONFIG`、`OPENCODE_CONFIG_DIR`、`OPENCODE_CONFIG_CONTENT` 与项目配置禁用;inline 内容有界且使用脱敏虚拟来源标识。完整配置 schema、配置变量替换、remote/managed 来源仍未实现 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | | 独立 `tui.json/jsonc` | 融合现有能力 + 转换参数 | 未实现 | 可完整适配 | OC-R1 | 按 global、`OPENCODE_TUI_CONFIG`、project、`.opencode` 独立顺序加载,不能复用主配置优先级 | [TUI 来源](opencode-config-assets-adapter-design.md#32-tui-独立来源顺序) | | Rules / Instructions | 转换参数 | 部分实现:完整本地配置顺序下的文件与 glob | 可完整适配 | OC-R1 | OpenCode adapter 已读取用户全局 `AGENTS.md`/Claude fallback,并按 OpenCode 的全局文件覆盖、后续本地来源去重追加规则合并 `instructions`;相对路径从 opened directory 向 project boundary 查找,禁用项目配置时回到用户配置根。Product Assembly 在 Codex/Claude 用户来源与项目来源之前合成并去重。远程 URL 与 managed/organization policy 仍未实现 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | -| Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集、模型/profile 绑定与 Agent-local 权限约束 | 可主要适配 | OC-R1 | 已支持当前生产 V1 `agent/prompt/disable/permission` 与 Core V2 `agents/system/disabled/permissions` 的已验证安全子集、全局/项目 Markdown 和 JSON/JSONC、subagent/all、description、`Default`/不透明模型引用、不透明 `variant` 意图和工具映射,并接入唯一精确匹配、显式 BitFun 模型绑定、Web/TUI 可见性、审批、冲突、更新、撤下和 fresh single-run Task;仅显式声明 `model` 的 Agent 才保留 `variant`,未声明模型时与 OpenCode 一样不生效。保留的 `variant` 不推断为 reasoning effort,也不生成请求级 override,需显式绑定现有配置。不维护厂商别名、质量推断或自动 fallback。V1 `disable` 保持 deep-merge,V2 `disabled` 保持 remove/re-add,不能混用生命周期语义。有序 V2 permission rules 与 V1 扁平精确 action map 会成为只可收紧的独立约束。primary/mode、root ambient permission、V1 action pattern/嵌套 resource map、跨路径与命令资源域的歧义 pattern、options、采样与续接明确阻断或降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | +| Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:静态 Agent 安全子集、role 投影、模型/profile 绑定与 Agent-local 权限约束 | 可主要适配 | OC-R1 | 已支持当前生产 V1 与 Core V2 的已验证安全子集、全局/项目 Markdown 和 JSON/JSONC、`primary/subagent/all`、description、模型/variant 意图和工具映射;同一 workspace route/generation registry 向 Web/TUI 主选择器和 fresh Task 投影,复用审批、冲突、更新、撤下与调用租约。未声明模型继承当前/父 Session,显式模型作为新主 Session 默认值且之后可修改;不维护厂商别名、质量推断或自动 fallback。V1/V2 生命周期与有序权限规则保持原语义,主 Agent 的外部 ask/deny 约束也进入子委派 ceiling。legacy mode、root ambient permission、V1 歧义 pattern、OpenCode task target 过滤、options、采样与续接仍明确阻断或降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | Skills | 转换参数 | 部分实现:标准根、本地配置根与标准用户根变化失效 | 可完整适配 | OC-R2 | 现有 Registry 除标准用户/项目根外,也通过 `bitfun-core/external_sources` 组合边界按 OpenCode 配置来源顺序累加 V1 `skills.paths` 与当前迁移后的本地字符串数组;仅接受项目根/用户目录内的本地目录并做有界递归发现。与 workspace 无关的标准用户根复用版本化快照,文件变化使其失效并在下一次发现时重建;OpenCode 配置根因作用域依赖当前 workspace 而保持按请求统一发现,标准项目根与 Remote 项目来源也仍按请求读取。同 scope 配置根覆盖标准 OpenCode 根,但不重排更早的 BitFun/Claude/Codex/Cursor 来源。URL、下载/缓存、完整 allow/deny/ask 顺序及外部来源策略仍未实现 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | References | 融合现有能力 + 转换参数 | 部分实现:本地目录与既有 Workspace 消费点 | 可主要适配 | OC-R2 | 已按统一的 OpenCode 本地配置来源顺序解析 `references`/旧 `reference` 的本地 path、description/hidden,相同 alias 后者覆盖;通过独立生命周期协调器与 BitFun 原生关联目录合成 native-first 有效快照,接入关联目录弹窗和既有 `@` 目录选择器。外部声明不自动进入 Prompt、不授予文件权限;Git、Remote、下载/缓存明确不支持且不做临时实现 | [References](opencode-config-assets-adapter-design.md#521-references) | | Commands | 补扩展接口 + 转换参数 | 部分实现:prompt、本地文本文件、经审阅的 shell 上下文与显式 Subagent 委派 | 可完整适配 | OC-R2 | 已支持全局/项目 JSON、JSONC、Markdown 命令、参数展开、动态目录、刷新和显式冲突选择;模板中的静态 workspace 相对 `@file` 可在调用时有界读取,`!shell` 经精确计划审阅后仅把 stdout 加入 Prompt,静态计划可记住、参数相关计划仅可单次运行。仅 `agent` 加缺省/`true` 的 `subtask` 可委派给同 workspace、同 OpenCode 生态、已审批且仍有效的精确 Subagent,并复用现有 fresh Task 生命周期;shell 与委派的组合、`model`、`variant`、`subtask: false`、隐式默认 Agent、Remote 与附件上下文保持受限,不回退到当前 Agent 或本机执行 | [Commands](opencode-config-assets-adapter-design.md#53-commands) | diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 36f61f805e..1f0d0d7ac6 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -6,7 +6,7 @@ export const forbiddenContentRules = [ reason: 'agent-runtime-ipc operation scope is frozen to the reviewed Shared TUI slice', patterns: [ { - regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SearchWorkspaceReferences|WorkspaceReferencesForMessage|GetSessionLineage|InspectLineageSession|CancelLineageSession|WorkspaceDiff|SubmitTurn|SteerTurn|RunUserShellCommand|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|SessionLineage|LineageSessionInspection|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnSteered|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogSteerRequest|AgentDialogTurnRequest|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionLineageCancellationRequest|AgentSessionLineageInspection|AgentSessionLineageRequest|AgentSessionLineageSnapshot|AgentSessionLineageTranscriptRequest|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentUserShellCommandRequest|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript|WorkspaceDiffSnapshot)\b)[A-Z][A-Za-z0-9_]*\b/, + regex: /^\s+(?!(?:Health|ListAgentModes|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SearchWorkspaceReferences|WorkspaceReferencesForMessage|GetSessionLineage|InspectLineageSession|CancelLineageSession|WorkspaceDiff|SubmitTurn|SteerTurn|RunUserShellCommand|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|AgentModes|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|SessionLineage|LineageSessionInspection|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnSteered|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogSteerRequest|AgentDialogTurnRequest|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionLineageCancellationRequest|AgentSessionLineageInspection|AgentSessionLineageRequest|AgentSessionLineageSnapshot|AgentSessionLineageTranscriptRequest|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentUserShellCommandRequest|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript|WorkspaceDiffSnapshot)\b)[A-Z][A-Za-z0-9_]*\b/, message: 'agent-runtime-ipc may not add archive, replay, observer, general controller-transfer, or other operations beyond the reviewed Shared TUI slice', }, diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index d78bd70af1..5df1dad4a0 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -1050,6 +1050,7 @@ export const externalSourceCorePublicApiEntries = [ 'set_external_prompt_command_conflict_choice', 'external_source_snapshot', 'external_source_read_only_snapshot', + 'ensure_external_source_workspace_snapshot', 'set_external_source_enabled', 'expand_external_prompt_command', 'sanitize_external_source_operation_error', diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index f7d85aaf00..7b5a639f2c 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -306,6 +306,14 @@ enum CliAgentRuntimeBackend { Shared(RuntimeIpcClient), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CliAgentMode { + pub(crate) id: String, + pub(crate) description: String, + pub(crate) model_id: Option, + pub(crate) is_external: bool, +} + type SharedBroadcast = Arc>>>; impl CliAgentRuntimeClient { @@ -353,6 +361,58 @@ impl CliAgentRuntimeClient { matches!(self.backend, CliAgentRuntimeBackend::Shared(_)) } + /// Read the main-agent catalog from the execution owner. Embedded and + /// Shared TUI expose the same small presentation projection; Shared never + /// consults the controller process's local registry. + pub(crate) async fn available_agent_modes(&self) -> Result> { + match &self.backend { + CliAgentRuntimeBackend::Embedded(_) => { + let workspace = self.workspace_path_buf(); + if let Err(error) = + bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( + &workspace, + )) + .await + { + tracing::warn!("Failed to initialize external agent sources: {error}"); + } + let registry = bitfun_core::agentic::agents::get_agent_registry(); + Ok(registry + .get_modes_info_for_workspace(Some(&workspace), true) + .await + .into_iter() + .map(|mode| CliAgentMode { + id: mode.id, + description: mode.description, + model_id: mode.model, + is_external: mode.source + == bitfun_core::agentic::agents::AgentSource::External, + }) + .collect()) + } + CliAgentRuntimeBackend::Shared(client) => { + let session_id = self.session_id.lock().await.clone(); + match client + .request(RuntimeIpcOperation::ListAgentModes { session_id }) + .await? + { + RuntimeIpcOperationResult::AgentModes { modes } => Ok(modes + .into_iter() + .map(|mode| CliAgentMode { + id: mode.id, + description: mode.description, + model_id: mode.model_id, + is_external: mode.is_external, + }) + .collect()), + other => Err(anyhow::anyhow!( + "Shared Runtime returned an unexpected result for list_agent_modes: {other:?}" + )), + } + } + } + } + fn embedded_runtime(&self, operation: &str) -> Result<&AgentRuntime> { match &self.backend { CliAgentRuntimeBackend::Embedded(runtime) => Ok(runtime), @@ -636,7 +696,7 @@ impl CliAgentRuntimeClient { let previous_summary = validated_session_summary(&sessions, session_id, &project_workspace)?; - let (restored, transcript, shared_pending) = match &self.backend { + let (restored, transcript, shared_pending, shared_workspace_binding) = match &self.backend { CliAgentRuntimeBackend::Embedded(runtime) => { let restored = runtime .restore_session(AgentSessionRestoreRequest { @@ -666,7 +726,7 @@ impl CliAgentRuntimeClient { messages: Vec::new(), } }); - (restored, transcript, None) + (restored, transcript, None, None) } CliAgentRuntimeBackend::Shared(client) => match client .request(RuntimeIpcOperation::RestoreSession { @@ -680,16 +740,26 @@ impl CliAgentRuntimeClient { { RuntimeIpcOperationResult::SessionRestored { session, + workspace_binding, transcript, pending_permissions, - } => (session, transcript, Some(pending_permissions)), + } => ( + session, + transcript, + Some(pending_permissions), + Some(workspace_binding), + ), _ => return Err(unexpected_shared_result("restore_session")), }, }; - let binding = self - .resolve_session_workspace_binding(session_id, &project_workspace) - .await?; + let binding = if let Some(binding) = shared_workspace_binding { + self.set_workspace_binding(&binding); + binding + } else { + self.resolve_session_workspace_binding(session_id, &project_workspace) + .await? + }; let mut session_id_guard = self.session_id.lock().await; let mut turn_id_guard = self.current_turn_id.lock().await; *session_id_guard = Some(session_id.to_string()); @@ -885,7 +955,7 @@ impl CliAgentRuntimeClient { )> { let source_session_id = self.require_session_id().await?; let workspace_path = self.project_workspace_path_string(); - let (session, transcript) = match &self.backend { + let (session, transcript, shared_workspace_binding) = match &self.backend { CliAgentRuntimeBackend::Embedded(runtime) => { let forked = match before_turn_id { Some(source_turn_id) => { @@ -928,7 +998,7 @@ impl CliAgentRuntimeClient { }) .await .map_err(|error| anyhow::anyhow!(error.into_message()))?; - (restored.session, transcript) + (restored.session, transcript, None) } CliAgentRuntimeBackend::Shared(client) => match client .request(RuntimeIpcOperation::ForkSession { @@ -941,15 +1011,20 @@ impl CliAgentRuntimeClient { { RuntimeIpcOperationResult::SessionForked { session, + workspace_binding, transcript, - } => (session, transcript), + } => (session, transcript, Some(workspace_binding)), _ => return Err(unexpected_shared_result("fork_session")), }, }; - let binding = self - .resolve_session_workspace_binding(&session.session_id, Path::new(&workspace_path)) - .await?; + let binding = if let Some(binding) = shared_workspace_binding { + self.set_workspace_binding(&binding); + binding + } else { + self.resolve_session_workspace_binding(&session.session_id, Path::new(&workspace_path)) + .await? + }; *self.session_id.lock().await = Some(session.session_id.clone()); *self.current_turn_id.lock().await = None; self.shared_pending_permissions @@ -1197,6 +1272,16 @@ impl CliAgentRuntimeClient { impl CliAgentRuntimeClient { pub(crate) async fn ensure_session(&self, agent_type: &str) -> Result { + self.ensure_session_with_model(agent_type, None).await + } + + /// Ensure the startup Session exists, preserving an explicit user model + /// selection in the authoritative creation request. + pub(crate) async fn ensure_session_with_model( + &self, + agent_type: &str, + model_id: Option, + ) -> Result { let mut session_id_guard = self.session_id.lock().await; if let Some(ref id) = *session_id_guard { @@ -1212,7 +1297,7 @@ impl CliAgentRuntimeClient { workspace_id: None, remote_connection_id: None, remote_ssh_host: None, - model_id: None, + model_id, metadata: serde_json::Map::new(), }; let session = match &self.backend { @@ -2118,6 +2203,23 @@ mod tests { assert!(!source.contains(&compatibility_update)); } + #[test] + fn startup_model_selection_is_sent_in_the_session_creation_request() { + let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); + let creation = source + .split_once("pub(crate) async fn ensure_session_with_model(") + .expect("explicit startup session creation method") + .1 + .split_once("pub(crate) async fn start_session_compaction(") + .expect("explicit startup session creation boundary") + .0; + + assert!(creation.contains("model_id: Option")); + assert!(creation.contains("AgentSessionCreateRequest")); + assert!(creation.contains("model_id,")); + assert!(creation.contains("RuntimeIpcOperation::CreateSession { request }")); + } + #[test] fn session_rename_uses_direct_runtime_or_private_shared_ipc() { let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index fe864e37e0..108b66fb7b 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -899,6 +899,16 @@ async fn run_interactive( let compatibility = runtime .as_ref() .map(|runtime| runtime.compatibility().clone()); + if !shared { + if let Err(error) = + bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( + &workspace_path, + )) + .await + { + tracing::warn!("Failed to initialize external agent sources: {error}"); + } + } // 3.5 Restore persisted account session (if any) if !shared { if let Some(user_id) = account::try_restore_session().await { @@ -950,6 +960,13 @@ async fn run_interactive( }; let agent_type = startup_page.agent_type().to_string(); + if matches!(startup_result, StartupResult::NewSession { .. }) { + if let Some(model_id) = startup_page.selected_model_id().map(str::to_string) { + agent + .ensure_session_with_model(&agent_type, Some(model_id)) + .await?; + } + } // Use the current project workspace selected at process start. let workspace = startup_page.workspace(); let config = startup_page.config().clone(); diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index 8063748b1b..5cd25d54e6 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -31,7 +31,20 @@ async fn ensure_global_config_service( pub(crate) async fn print_agents(workspace: Option<&Path>) -> Result<()> { let registry = get_agent_registry(); - let modes = registry.get_modes_info().await; + if workspace.is_some() { + if let Err(error) = + bitfun_core::external_sources::ensure_external_source_workspace_snapshot(workspace) + .await + { + eprintln!( + "Warning: external agent sources could not be refreshed: {}", + crate::plugin_diagnostics::escape_terminal_text(&error) + ); + } + } + let modes = registry + .get_modes_info_for_workspace(workspace, workspace.is_some()) + .await; let subagents = registry.get_subagents_info(workspace).await; println!("Agent modes"); @@ -628,7 +641,13 @@ pub(crate) async fn print_doctor(product_runtime: &ProductRuntimeParts) -> Resul let config_service = ensure_global_config_service().await?; let models = config_service.get_ai_models().await?; let agent_registry = get_agent_registry(); - let modes = agent_registry.get_modes_info().await; + let external_source_error = + bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some(&workspace)) + .await + .err(); + let modes = agent_registry + .get_modes_info_for_workspace(Some(&workspace), true) + .await; let subagents = agent_registry .get_subagents_info(Some(workspace.as_path())) .await; @@ -690,6 +709,14 @@ pub(crate) async fn print_doctor(product_runtime: &ProductRuntimeParts) -> Resul println!("[ok] Config directory: {}", config_dir.display()); println!("[ok] Agent modes: {}", modes.len()); println!("[ok] Subagents: {}", subagents.len()); + if let Some(error) = external_source_error.as_deref() { + println!( + "[warn] External agent sources were not refreshed: {}", + crate::plugin_diagnostics::escape_terminal_text(error) + ); + } else { + println!("[ok] External agent sources: refreshed"); + } println!( "[ok] AI models: {} total, {} enabled", models.len(), @@ -725,14 +752,17 @@ pub(crate) async fn print_doctor(product_runtime: &ProductRuntimeParts) -> Resul ); } println!(); + let doctor_checks_ready = external_source_error.is_none() && plugin_sources_ready; if !plugin_sources_ready { println!("Doctor checks found plugin source errors."); + } else if external_source_error.is_some() { + println!("Doctor checks completed with an external agent source warning."); } else if plugin_warning_count > 0 { println!("Doctor checks completed with plugin warnings."); } else { println!("Doctor checks passed."); } - Ok(plugin_sources_ready) + Ok(doctor_checks_ready) } #[cfg(test)] diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 99df6fed81..17834010c2 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -35,7 +35,7 @@ use crate::actions::{ SHARED_TUI_HELP_NOTE, }; use crate::agent::context_reload_client::CliContextReloadClient; -use crate::agent::runtime_client::{CliAgentRuntimeClient, SessionOperationError}; +use crate::agent::runtime_client::{CliAgentMode, CliAgentRuntimeClient, SessionOperationError}; use crate::chat_state::{ChatState, ModelTokenUsageSnapshot}; use crate::config::CliConfig; use crate::ui::agent_selector::{AgentItem, AgentSelectorAction}; diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 953fb2f486..76635759f9 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -409,21 +409,33 @@ impl ChatMode { // Create new session let agent = self.agent.clone(); let agent_type = self.agent_type.clone(); - let (session_id, workspace_binding) = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let session_id = agent.ensure_session(&agent_type).await?; - let binding = agent.session_workspace_binding(&session_id).await?; - Ok::<_, anyhow::Error>((session_id, binding)) - }) - })?; + let (session_id, workspace_binding, session_summary) = + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let session_id = agent.ensure_session(&agent_type).await?; + let binding = agent.session_workspace_binding(&session_id).await?; + let summary = agent + .list_sessions() + .await? + .into_iter() + .find(|summary| summary.session_id == session_id) + .ok_or_else(|| { + anyhow::anyhow!( + "Created Session is missing from the Runtime catalog" + ) + })?; + Ok::<_, anyhow::Error>((session_id, binding, summary)) + }) + })?; tracing::info!("Core session ready: {}", session_id); let mut state = ChatState::new( session_id.clone(), - "CLI Session".to_string(), + session_summary.session_name, self.agent_type.clone(), Some(workspace_binding.workspace_path.clone()), ); + state.current_model_id = session_summary.model_id; state.apply_workspace_binding(workspace_binding); (session_id, state, Vec::new()) }; diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 4e8af80f65..b3a6c6387a 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -386,10 +386,15 @@ impl ChatMode { chat_view.set_status(Some(format!("Theme set to: {}", theme.id))); } - fn get_mode_agents(&self, rt_handle: &tokio::runtime::Handle) -> Vec { - let registry = get_agent_registry(); - let modes = tokio::task::block_in_place(|| rt_handle.block_on(registry.get_modes_info())); - modes + fn get_mode_agents(&self, rt_handle: &tokio::runtime::Handle) -> Vec { + tokio::task::block_in_place(|| { + rt_handle + .block_on(self.agent.available_agent_modes()) + .unwrap_or_else(|error| { + tracing::warn!("Failed to load main agent modes: {error}"); + Vec::new() + }) + }) } fn cycle_agent( @@ -706,10 +711,6 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - if selected.id == self.agent_type { - return; - } - if self.pending_session_operation.is_some() { chat_view.set_status(Some( "A Session operation is already in progress. Please wait.".to_string(), diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index f0489429e5..9d326a3d07 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -49,6 +49,7 @@ mod tests { ExternalSourceOperationErrorCode, ExternalSubagentActivationState, ExternalToolActivationState, }; + use bitfun_core::native_hooks::{ NativeHookFileView, NativeHookHandlerView, NativeHookOverview, NativeHookRuleView, }; @@ -59,6 +60,21 @@ mod tests { use crossterm::event::Event; use std::collections::{BTreeMap, BTreeSet}; + #[test] + fn explicit_same_id_agent_selection_rebinds_through_the_runtime_owner() { + let source = include_str!("selection.rs").replace("\r\n", "\n"); + let selection = source + .split_once("fn apply_agent_selection(") + .expect("agent selection method") + .1 + .split_once("fn poll_session_operation_completion(") + .expect("agent selection boundary") + .0; + + assert!(selection.contains(".update_session_mode(&task_session_id, &task_mode_id)")); + assert!(!selection.contains("selected.id == self.agent_type")); + } + #[test] fn reload_command_uses_one_closed_optional_target() { assert_eq!( @@ -2652,8 +2668,9 @@ mod tests { assert_eq!(steering_unsupported_reason(&plain), None); let mut referenced = plain.clone(); - referenced.workspace_references.push( - bitfun_agent_runtime::sdk::AgentWorkspaceReference { + referenced + .workspace_references + .push(bitfun_agent_runtime::sdk::AgentWorkspaceReference { path: "src/lib.rs".to_string(), kind: bitfun_agent_runtime::sdk::AgentWorkspaceReferenceKind::File, start_line: None, @@ -2663,8 +2680,7 @@ mod tests { end: 11, value: "@src/lib.rs".to_string(), }, - }, - ); + }); assert!(steering_unsupported_reason(&referenced) .expect("workspace reference rejection") .contains("Workspace references")); diff --git a/src/apps/cli/src/peer_host/commands/external_sources.rs b/src/apps/cli/src/peer_host/commands/external_sources.rs index 7a11f5be94..07c9ff545a 100644 --- a/src/apps/cli/src/peer_host/commands/external_sources.rs +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -76,7 +76,7 @@ fn model_binding_target_field( } } -async fn workspace_root( +pub(super) async fn workspace_root( state: &PeerHostState, request: &Value, ) -> ExternalSourceOperationResult> { diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 24ad3c4817..56a103d09f 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -83,9 +83,10 @@ pub(crate) async fn dispatch( "archive_session" => session::archive_session(state, args).await, "touch_session_activity" => session::touch_session_activity(state, args).await, "get_session_thread_goal" => session::get_session_thread_goal(state, args).await, + "update_session_mode" => session::update_session_mode(state, args).await, "update_session_model" => session::update_session_model(state, args).await, "ensure_coordinator_session" => session::ensure_coordinator_session(state, args).await, - "get_available_modes" => session::get_available_modes().await, + "get_available_modes" => session::get_available_modes(state, args).await, "get_session_stats" => session::get_session_stats(state, args).await, "save_session_turn" => session::save_session_turn(state, args).await, diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index bbd21b2c7a..6e43b9cf92 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -13,8 +13,8 @@ use bitfun_core::agentic::get_agent_registry; use bitfun_core::util::errors::BitFunError; use bitfun_runtime_ports::{ AgentSessionArchiveRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, - AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentThreadGoalGetRequest, - SessionStoragePathRequest, SessionTurnWindowRequest, + AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRenameRequest, + AgentThreadGoalGetRequest, SessionStoragePathRequest, SessionTurnWindowRequest, }; use crate::diagnostics::{OUTCOME_UNKNOWN_ERROR_CODE, SESSION_IN_USE_ERROR_CODE}; @@ -513,6 +513,31 @@ pub(crate) async fn update_session_model( Ok(Value::Null) } +pub(crate) async fn update_session_mode( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let session_id = validated_session_id(request)?; + let mode_id = get_string(request, "modeId")?; + if request + .get("workspacePath") + .and_then(Value::as_str) + .is_some_and(|path| !path.trim().is_empty()) + { + ensure_coordinator_session(state, args).await?; + } + state + .agent_runtime + .update_session_mode(AgentSessionModeUpdateRequest { + session_id, + mode_id, + }) + .await + .map_err(|error| format!("Failed to update session mode: {}", error.into_message()))?; + Ok(Value::Null) +} + pub(crate) async fn ensure_coordinator_session( state: &PeerHostState, args: &Value, @@ -538,8 +563,29 @@ pub(crate) async fn ensure_coordinator_session( .map_err(|error| peer_core_session_error("Failed to ensure session", error)) } -pub(crate) async fn get_available_modes() -> Result { - let mode_infos = get_agent_registry().get_modes_info().await; +pub(crate) async fn get_available_modes( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let workspace = super::external_sources::workspace_root(state, request) + .await + .map_err(|error| error.encode())?; + if let Some(workspace) = workspace.as_deref() { + if let Err(error) = + bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( + workspace, + )) + .await + { + tracing::warn!( + "Failed to initialize external agent sources for Peer mode catalog: {error}" + ); + } + } + let mode_infos = get_agent_registry() + .get_modes_info_for_workspace(workspace.as_deref(), workspace.is_some()) + .await; let dtos: Vec = mode_infos .into_iter() .map(|info| { diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 6ef374d358..4ad050d292 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -7,14 +7,15 @@ use bitfun_agent_runtime::sdk::{ PortErrorKind, RuntimeError, SessionTranscriptRequest, }; use bitfun_agent_runtime_ipc::{ - DiscoveryStore, RuntimeInstanceIdentity, RuntimeIpcClient, RuntimeIpcError, - RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeIpcRequestHandler, RuntimeIpcServer, RuntimeIpcServerConfig, + DiscoveryStore, RuntimeAgentModeSummary, RuntimeInstanceIdentity, RuntimeIpcClient, + RuntimeIpcError, RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcOperation, + RuntimeIpcOperationResult, RuntimeIpcRequestHandler, RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeIpcStreamInvalidationReason, RuntimeSessionRenameRequest, PROTOCOL_VERSION, }; use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::runtime_ownership::CoreRuntimeOwnership; use bitfun_events::{AgenticEvent, ToolEventData}; +use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest}; use bitfun_services_core::runtime_ownership::RuntimeDeployment; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -223,6 +224,41 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { self.validate_workspace(&operation)?; match operation { RuntimeIpcOperation::Health => unreachable!("Health is owned by the IPC server"), + RuntimeIpcOperation::ListAgentModes { session_id } => { + let workspace = match session_id { + Some(session_id) => PathBuf::from( + self.session_workspace_binding(&session_id) + .await? + .workspace_path, + ), + None => self.workspace.clone(), + }; + if let Err(error) = + bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( + &workspace, + )) + .await + { + tracing::warn!( + "Failed to initialize external agent sources for Shared TUI mode catalog: {}", + error + ); + } + let registry = bitfun_core::agentic::agents::get_agent_registry(); + let modes = registry + .get_modes_info_for_workspace(Some(&workspace), true) + .await + .into_iter() + .map(|mode| RuntimeAgentModeSummary { + id: mode.id, + description: mode.description, + model_id: mode.model, + is_external: mode.source + == bitfun_core::agentic::agents::AgentSource::External, + }) + .collect(); + Ok(RuntimeIpcOperationResult::AgentModes { modes }) + } RuntimeIpcOperation::ListSessions { request } => self .runtime .list_sessions(request) @@ -268,8 +304,12 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { ) }) .collect(); + let workspace_binding = self + .session_workspace_binding(&restored.session.session_id) + .await?; Ok(RuntimeIpcOperationResult::SessionRestored { session: restored.session, + workspace_binding, transcript, pending_permissions, }) @@ -319,8 +359,12 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { }) .await .map_err(runtime_ipc_error)?; + let workspace_binding = self + .session_workspace_binding(&restored.session.session_id) + .await?; Ok(RuntimeIpcOperationResult::SessionForked { session: restored.session, + workspace_binding, transcript, }) } @@ -661,6 +705,19 @@ async fn await_permission_route( } impl SharedRuntimeHandler { + async fn session_workspace_binding( + &self, + session_id: &str, + ) -> std::result::Result { + self.runtime + .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { + session_id: session_id.to_string(), + }) + .await + .map_err(runtime_ipc_error)? + .ok_or_else(workspace_mismatch_error) + } + fn validate_workspace( &self, operation: &RuntimeIpcOperation, diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 11c4844af9..4004cd3aff 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -56,7 +56,7 @@ use bitfun_core::agentic::tools::implementations::skills::{ use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::service::config::GlobalConfigManager; -use crate::agent::runtime_client::CliAgentRuntimeClient; +use crate::agent::runtime_client::{CliAgentMode, CliAgentRuntimeClient}; /// Types of popups that can be shown on the startup page #[derive(Debug, Clone, PartialEq)] @@ -213,6 +213,9 @@ pub(crate) struct StartupPage { agent_type: String, /// Display name of selected model model_display_name: String, + /// Explicit model chosen for the new Session being composed. Persisted + /// defaults and an agent profile remain inputs only until the user chooses. + selected_model_id: Option, /// Workspace path for display in bottom bar workspace_display: String, /// Status message (temporarily shown instead of tip) @@ -293,6 +296,7 @@ impl StartupPage { compatibility, agent_type: default_agent, model_display_name: String::new(), + selected_model_id: None, workspace_display: workspace.unwrap_or_else(|| { std::env::current_dir() .ok() @@ -315,6 +319,11 @@ impl StartupPage { &self.agent_type } + /// Return the model explicitly selected for the new Session, if any. + pub(crate) fn selected_model_id(&self) -> Option<&str> { + self.selected_model_id.as_deref() + } + /// Get the current workspace path for this CLI process. pub(crate) fn workspace(&self) -> Option { if self.workspace_display.is_empty() { @@ -1576,6 +1585,8 @@ impl StartupPage { fn show_model_selector(&mut self) { self.push_current_popup_to_stack(); + let profile_model_id = self.selected_agent_mode().and_then(|mode| mode.model_id); + let explicitly_selected_model_id = self.selected_model_id.clone(); let result = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { @@ -1585,8 +1596,11 @@ impl StartupPage { let global_config: bitfun_core::service::config::GlobalConfig = config_service.get_config(None).await.ok()?; - let current_model_id = - crate::model_selection::resolve_mode_model_id(&global_config.ai); + let current_model_id = resolve_startup_model_id( + explicitly_selected_model_id, + profile_model_id, + crate::model_selection::resolve_mode_model_id(&global_config.ai), + ); let model_items: Vec = models .into_iter() @@ -1616,9 +1630,15 @@ impl StartupPage { fn apply_model_selection(&mut self, selected: &ModelItem) { let selected_id = selected.id.clone(); let selected_display_name = format!("{} / {}", selected.model_name, selected.name); + let selected_agent_mode = self.selected_agent_mode(); + let persist_shared_default = + should_persist_shared_model_default(selected_agent_mode.as_ref()); let success = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { + if !persist_shared_default { + return true; + } let config_service = match GlobalConfigManager::get_service().await { Ok(s) => s, Err(_) => return false, @@ -1637,9 +1657,12 @@ impl StartupPage { }); if success { + self.selected_model_id = Some(selected_id); self.model_display_name = selected_display_name.clone(); self.status = Some(format!("Model switched to: {}", selected_display_name)); - crate::account_sync::notify_local_settings_changed(); + if persist_shared_default { + crate::account_sync::notify_local_settings_changed(); + } } else { self.status = Some("Failed to switch model".to_string()); } @@ -2430,12 +2453,21 @@ impl StartupPage { self.popup_stack.clear(); } - fn get_mode_agents(&self) -> Vec { - let registry = get_agent_registry(); - let modes = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(registry.get_modes_info()) - }); - modes + fn get_mode_agents(&self) -> Vec { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.agent.available_agent_modes()) + .unwrap_or_else(|error| { + tracing::warn!("Failed to load main agent modes: {error}"); + Vec::new() + }) + }) + } + + fn selected_agent_mode(&self) -> Option { + self.get_mode_agents() + .into_iter() + .find(|mode| mode.id == self.agent_type) } fn cycle_agent(&mut self, offset: isize) { @@ -2458,6 +2490,8 @@ impl StartupPage { } fn load_current_model_name(&mut self) { + let explicitly_selected_model_id = self.selected_model_id.clone(); + let profile_model_id = self.selected_agent_mode().and_then(|mode| mode.model_id); let result: Option = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { let config_service = GlobalConfigManager::get_service().await.ok()?; @@ -2466,7 +2500,11 @@ impl StartupPage { let global_config: bitfun_core::service::config::GlobalConfig = config_service.get_config(None).await.ok()?; - let model_id = crate::model_selection::resolve_mode_model_id(&global_config.ai)?; + let model_id = resolve_startup_model_id( + explicitly_selected_model_id, + profile_model_id, + crate::model_selection::resolve_mode_model_id(&global_config.ai), + )?; fn provider_display_name( model: &bitfun_core::service::config::AIModelConfig, @@ -2517,11 +2555,67 @@ impl StartupPage { } } +fn resolve_startup_model_id( + explicitly_selected_model_id: Option, + profile_model_id: Option, + default_model_id: Option, +) -> Option { + explicitly_selected_model_id + .or(profile_model_id) + .or(default_model_id) +} + +fn should_persist_shared_model_default(mode: Option<&CliAgentMode>) -> bool { + mode.is_some_and(|mode| !mode.is_external) +} + #[cfg(test)] mod logo_contract_tests { use super::*; use ratatui::style::Color; + #[test] + fn explicit_startup_model_overrides_profile_and_default() { + assert_eq!( + resolve_startup_model_id( + Some("explicit".to_string()), + Some("profile".to_string()), + Some("default".to_string()), + ) + .as_deref(), + Some("explicit") + ); + assert_eq!( + resolve_startup_model_id( + None, + Some("profile".to_string()), + Some("default".to_string()), + ) + .as_deref(), + Some("profile") + ); + } + + #[test] + fn external_or_unknown_startup_modes_do_not_change_the_shared_default() { + let local = CliAgentMode { + id: "agentic".to_string(), + description: String::new(), + model_id: None, + is_external: false, + }; + let external = CliAgentMode { + id: "reviewer".to_string(), + description: String::new(), + model_id: None, + is_external: true, + }; + + assert!(should_persist_shared_model_default(Some(&local))); + assert!(!should_persist_shared_model_default(Some(&external))); + assert!(!should_persist_shared_model_default(None)); + } + #[test] fn fancy_logo_keeps_line_order_and_color_style_mapping() { let expected = [ diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index d974d7ddb7..e463c90da8 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -17,9 +17,10 @@ use crate::startup_trace::DesktopStartupTrace; use bitfun_agent_runtime::deep_review::sanitize_focused_review_public_metadata; use bitfun_agent_runtime::sdk::{ AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, - AgentInputAttachment, AgentSessionCreateResult, AgentSessionModelUpdateRequest, - AgentSubmissionSource, AgentTurnCancellationRequest, DialogSteerOutcome, PermissionAuditRecord, - PermissionGrant, PermissionGrantKey, PermissionReply, PermissionRequest, + AgentInputAttachment, AgentSessionCreateResult, AgentSessionModeUpdateRequest, + AgentSessionModelUpdateRequest, AgentSubmissionSource, AgentTurnCancellationRequest, + DialogSteerOutcome, PermissionAuditRecord, PermissionGrant, PermissionGrantKey, + PermissionReply, PermissionRequest, }; use bitfun_core::agentic::agents::AgentSource; use bitfun_core::agentic::coordination::{ @@ -49,6 +50,7 @@ use bitfun_core::service::config::project_permission_store::{ deserialize_project_permission_config, project_permission_file_path, project_permission_file_path_for_remote, ProjectPermissionConfig, }; +use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_core::service::remote_ssh::workspace_state::resolve_workspace_session_identity; use bitfun_core::service::session::{ DialogTurnData, SessionMemoryMode, SessionMetadata, SessionRelationship, @@ -200,6 +202,8 @@ fn existing_session_create_response( metadata.agent_type.clone(), ); response.workspace_path = metadata.workspace_path.clone(); + response.model_id = + (!metadata.model_name.trim().is_empty()).then(|| metadata.model_name.clone()); response.workspace_id = request.workspace_id.clone(); response.project_workspace_path = metadata.project_workspace_path.clone(); response.execution_target = metadata.execution_target.clone(); @@ -234,6 +238,21 @@ pub struct UpdateSessionModelRequest { pub include_internal: bool, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSessionModeRequest { + pub session_id: String, + pub mode_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + #[serde(default)] + pub include_internal: bool, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateSessionTitleRequest { @@ -1686,6 +1705,34 @@ pub async fn create_session( Ok(session.into()) } +#[tauri::command] +pub async fn update_session_mode( + runtime: State<'_, DesktopRuntimeContext>, + request: UpdateSessionModeRequest, +) -> Result<(), String> { + let session_id = request.session_id.trim().to_string(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + ensure_session_loaded_for_selector_update( + runtime.inner(), + &session_id, + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + request.include_internal, + ) + .await?; + runtime + .agent_runtime() + .update_session_mode(AgentSessionModeUpdateRequest { + session_id, + mode_id: request.mode_id, + }) + .await + .map_err(|error| format!("Failed to update session mode: {}", error.into_message())) +} + #[tauri::command] pub async fn update_session_model( runtime: State<'_, DesktopRuntimeContext>, @@ -1695,26 +1742,15 @@ pub async fn update_session_model( if session_id.is_empty() { return Err("session_id is required".to_string()); } - if let Some(workspace_path) = request - .workspace_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty()) - { - runtime - .session_application() - .ensure_session_loaded( - desktop_session_scope( - workspace_path.to_string(), - request.remote_connection_id, - request.remote_ssh_host, - ), - &session_id, - request.include_internal, - ) - .await - .map_err(|error| format!("Failed to restore session before model update: {error}"))?; - } + ensure_session_loaded_for_selector_update( + runtime.inner(), + &session_id, + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + request.include_internal, + ) + .await?; runtime .agent_runtime() .update_session_model(AgentSessionModelUpdateRequest { @@ -1725,6 +1761,37 @@ pub async fn update_session_model( .map_err(|error| format!("Failed to update session model: {}", error.into_message())) } +async fn ensure_session_loaded_for_selector_update( + runtime: &DesktopRuntimeContext, + session_id: &str, + workspace_path: Option, + remote_connection_id: Option, + remote_ssh_host: Option, + include_internal: bool, +) -> Result<(), String> { + let Some(workspace_path) = workspace_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + else { + return Ok(()); + }; + runtime + .session_application() + .ensure_session_loaded( + desktop_session_scope( + workspace_path.to_string(), + remote_connection_id, + remote_ssh_host, + ), + session_id, + include_internal, + ) + .await + .map_err(|error| format!("Failed to restore session before selector update: {error}"))?; + Ok(()) +} + #[tauri::command] pub async fn reload_session_context( runtime: State<'_, DesktopRuntimeContext>, @@ -3236,9 +3303,31 @@ pub async fn generate_session_title( pub async fn get_available_modes( state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, + request: Option, ) -> Result, String> { let trace_started = Instant::now(); - let mode_infos = state.agent_registry.get_modes_info().await; + let request = request.unwrap_or_default(); + let workspace_path = request + .workspace_path + .as_deref() + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from); + let external_sources_supported = + mode_catalog_supports_external_sources(&request, workspace_path.as_deref()).await; + if external_sources_supported { + if let Err(error) = + bitfun_core::external_sources::ensure_external_source_workspace_snapshot( + workspace_path.as_deref(), + ) + .await + { + warn!("Failed to initialize external agent sources for mode selector: {error}"); + } + } + let mode_infos = state + .agent_registry + .get_modes_info_for_workspace(workspace_path.as_deref(), external_sources_supported) + .await; let dtos: Vec = mode_infos .into_iter() @@ -3269,6 +3358,36 @@ pub async fn get_available_modes( Ok(dtos) } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAvailableModesRequest { + pub workspace_path: Option, + pub remote_connection_id: Option, + pub remote_ssh_host: Option, +} + +async fn mode_catalog_supports_external_sources( + request: &GetAvailableModesRequest, + workspace_path: Option<&Path>, +) -> bool { + let has_remote_identity = request + .remote_connection_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || request + .remote_ssh_host + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + if has_remote_identity { + return false; + } + + match workspace_path { + Some(path) => path.is_absolute() && !is_remote_path(&path.to_string_lossy()).await, + None => false, + } +} + #[tauri::command] pub async fn get_default_review_team_definition() -> Result { Ok(default_review_team_definition()) @@ -3385,6 +3504,19 @@ mod tests { use bitfun_product_domains::tool_permissions::{PermissionEffect, PermissionRule}; use serde_json::json; + #[tokio::test] + async fn remote_mode_catalog_never_scans_an_absolute_desktop_host_path() { + let desktop_host_path = std::env::current_dir().expect("desktop host working directory"); + assert!(desktop_host_path.is_absolute()); + let request = GetAvailableModesRequest { + workspace_path: Some(desktop_host_path.to_string_lossy().into_owned()), + remote_connection_id: Some("remote-1".to_string()), + remote_ssh_host: Some("build-host".to_string()), + }; + + assert!(!mode_catalog_supports_external_sources(&request, Some(&desktop_host_path)).await); + } + #[test] fn desktop_steering_uses_the_same_agent_runtime_port_as_other_surfaces() { let source = include_str!("agentic_api.rs").replace("\r\n", "\n"); diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index b4bc07a3ed..7f2419148f 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1935,6 +1935,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("update_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), + ("update_session_mode", RemoteWorkspacePolicy::RemoteRouted), ( "update_session_model", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index f42f0bc515..68542ec832 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1120,6 +1120,7 @@ pub async fn run() { appearance::show_main_window, hide_main_window_after_close_request, api::agentic_api::create_session, + api::agentic_api::update_session_mode, api::agentic_api::update_session_model, api::agentic_api::reload_session_context, api::agentic_api::update_session_title, diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md index 3ea157886d..c100aad26e 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md @@ -15,7 +15,7 @@ ## 边界 - 只导出 CLI adapter 实际使用的 workspace-private API,且 crate 不得发布,也不得把 wire 作为 SDK 合同。 -- 封闭 operation 范围为 Health、Session list/create/restore/delete/fork(restore/fork 结果包含 transcript)、当前 Session rename、Agent mode/model update、手动 context compaction、Session undo/redo、current-controller 限定的只读工作区引用搜索/持久化引用读取,以及不取得 Session lease 的 Runtime 绑定工作区只读 diff;此外还包括声明式上下文 reload、Turn submit/steer、用户显式 Shell 执行/cancel、pending/respond Permission 和 UserInput answers。delete 只允许作用于未被任何 Client 控制的空闲 Session。fork 要求当前 controller 且 Session 空闲:可以复制到最新持久化 Turn,也可以停在显式选中 Turn 之前;只有包含新 Session 与 transcript 的成功结果完成编码后,Server 才能把连接 lease 从源 Session 原子切换到 fork。手动 compaction 要求当前 controller 且 Session 空闲;Client 在准入前提供精确 Turn ID,使超时或断连 cleanup 可以取消同一个 owned task;Core 开始原子 context commit 后,晚到取消不能暴露错误的空闲状态。用户显式 Shell 执行同样要求当前 controller、Session 空闲和调用方提供的 Turn ID;它只委托给窄 Runtime port,并复用正常 ToolPipeline、权限、工作区路由、持久化与取消 owner,不是通用 Tool 或进程执行 wire。steer 要求当前 controller、活动 Turn 以及调用方提供的精确 Session/Turn ID;它只委托给共享 Runtime owner,拒绝过期投影,不创建第二个 Turn 或 queue owner。undo/redo 要求当前 controller,但可在活动 Turn 中进入,因为取消、drain 与回退写入顺序由 Core 统一负责;成功结果携带权威 transcript,并清除连接侧活动 Turn 投影。该能力只支持本地工作区,不暴露通用 checkpoint 协议。上下文 reload 可在活动 Turn 中执行,不改写该 Turn,并通过缓存保护保证下一条消息重新读取已失效的 instructions。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录和默认值仍是 wire 之外的产品配置;禁止顺带加入 archive、replay、observer、通用 controller transfer、Tool/MCP/Hook 管理或其他产品配置。 +- 封闭 operation 范围为 Health、只读且 workspace-scoped 的 main Agent 摘要、Session list/create/restore/delete/fork(restore/fork 结果包含 transcript)、当前 Session rename、Agent mode/model update、手动 context compaction、Session undo/redo、current-controller 限定的只读工作区引用搜索/持久化引用读取,以及不取得 Session lease 的 Runtime 绑定工作区只读 diff;此外还包括声明式上下文 reload、Turn submit/steer、用户显式 Shell 执行/cancel、pending/respond Permission 和 UserInput answers。delete 只允许作用于未被任何 Client 控制的空闲 Session。fork 要求当前 controller 且 Session 空闲:可以复制到最新持久化 Turn,也可以停在显式选中 Turn 之前;只有包含新 Session 与 transcript 的成功结果完成编码后,Server 才能把连接 lease 从源 Session 原子切换到 fork。手动 compaction 要求当前 controller 且 Session 空闲;Client 在准入前提供精确 Turn ID,使超时或断连 cleanup 可以取消同一个 owned task;Core 开始原子 context commit 后,晚到取消不能暴露错误的空闲状态。用户显式 Shell 执行同样要求当前 controller、Session 空闲和调用方提供的 Turn ID;它只委托给窄 Runtime port,并复用正常 ToolPipeline、权限、工作区路由、持久化与取消 owner,不是通用 Tool 或进程执行 wire。steer 要求当前 controller、活动 Turn 以及调用方提供的精确 Session/Turn ID;它只委托给共享 Runtime owner,拒绝过期投影,不创建第二个 Turn 或 queue owner。undo/redo 要求当前 controller,但可在活动 Turn 中进入,因为取消、drain 与回退写入顺序由 Core 统一负责;成功结果携带权威 transcript,并清除连接侧活动 Turn 投影。该能力只支持本地工作区,不暴露通用 checkpoint 协议。上下文 reload 可在活动 Turn 中执行,不改写该 Turn,并通过缓存保护保证下一条消息重新读取已失效的 instructions。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录、默认值和完整 Agent/Subagent 管理仍是 wire 之外的产品配置;main Agent 摘要只服务 host-owned selector:启动页查询 Runtime 绑定工作区且不取得 Session lease,Session 级查询则要求该 Session 的当前 controller 并由 Runtime owner 解析执行工作区。摘要中的 external-source 分类保持生态无关,且不携带安装、变更、激活或 runtime lifecycle API。禁止顺带加入 archive、replay、observer、通用 controller transfer、Tool/MCP/Hook 管理或其他产品配置。 - 可以复用稳定 Event、Product Domain 和 Runtime Port DTO。禁止依赖 `bitfun-core`、Agent Runtime 实现、SDK Host、services、Tauri、terminal、tool runtime 或远程 transport。 - 只使用 Windows Named Pipe 或 Unix Domain Socket;禁止 TCP、HTTP、WebSocket、浏览器访问或远程 fallback。 - 这是本机同用户隔离,不是沙箱。未来产品 composition 必须提供当前用户私有 runtime 目录。 diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS.md b/src/crates/adapters/agent-runtime-ipc/AGENTS.md index eef2695f02..13c7e4c775 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS.md @@ -22,7 +22,7 @@ session controller leases, event delivery, connection bounds, and cleanup. It is - Export only the exact workspace-private API needed by the CLI adapter. Do not publish this crate or expose its wire as an SDK contract. -- The closed operation budget is Health, Session list/create/restore/delete/fork (including transcript on restore/fork), current-Session rename, Agent mode/model update, manual context compaction, Session undo/redo, current-controller read-only workspace-reference search/persisted-reference lookup, root-scoped Session-lineage query/descendant transcript read/targeted descendant cancellation, and a read-only diff of the Runtime-bound workspace that does not acquire a Session lease, +- The closed operation budget is Health, a read-only workspace-scoped main-Agent mode summary, Session list/create/restore/delete/fork (including transcript on restore/fork), current-Session rename, Agent mode/model update, manual context compaction, Session undo/redo, current-controller read-only workspace-reference search/persisted-reference lookup, root-scoped Session-lineage query/descendant transcript read/targeted descendant cancellation, and a read-only diff of the Runtime-bound workspace that does not acquire a Session lease, declarative context reload, Turn submit/steer/user-authored Shell execution/cancel, pending/respond Permission, and UserInput answers. Delete is limited to an idle Session not controlled by any client. Fork is a current-controller, idle-only operation. It either copies through the latest persisted Turn or stops immediately before an explicitly selected Turn. The encoded success result carries the authoritative new Session and transcript; only then may the server atomically switch the connection lease from the source Session to the fork. Manual compaction is a current-controller, idle-only Turn operation. The client supplies its exact Turn ID before admission so timeout or disconnect cleanup can cancel the same owned task; once Core begins the atomic context commit, a late cancellation does not expose a false idle state. @@ -32,7 +32,7 @@ session controller leases, event delivery, connection bounds, and cleanup. It is Context reload may run during an active Turn, does not rewrite that Turn, and guards the cache so the next message reads invalidated instructions. Undo/redo is a current-controller operation that may enter during an active Turn because Core owns cancel-and-drain before mutation. Its success response carries the authoritative transcript and clears the connection's active-Turn projection. It is local-workspace only and does not expose a generic checkpoint protocol. Disconnect cleanup is internal lifecycle, not a detach operation. - Model catalogs and defaults remain product configuration outside this wire. Do not add archive, replay, observer, + Model catalogs, defaults, and full Agent/Subagent management remain product configuration outside this wire. The main-Agent summary is only the minimal host-owned selector projection: startup reads the Runtime-bound workspace without a Session lease, while a Session-scoped query requires that Session's current controller and lets the Runtime owner resolve its execution workspace. Its external-source classification is ecosystem-neutral, and it carries no installation, mutation, activation, or runtime lifecycle API. Do not add archive, replay, observer, general controller transfer, Tool/MCP/Hook management, or other product configuration incidentally. - Stable Event, Product Domain, and Runtime Port DTOs may be reused. Do not depend on `bitfun-core`, Agent Runtime implementations, SDK Host, services, diff --git a/src/crates/adapters/agent-runtime-ipc/src/lib.rs b/src/crates/adapters/agent-runtime-ipc/src/lib.rs index 39a991ca4b..7ef31bb826 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/lib.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/lib.rs @@ -25,8 +25,9 @@ pub use handler::RuntimeIpcRequestHandler; pub use ipc::RuntimeIpcTransportError; pub(crate) use ipc::{LocalIpcEndpoint, LocalIpcListener, LocalIpcStream}; pub use operation::{ - RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeSessionForkRequest, - RuntimeSessionRenameRequest, RuntimeSessionRestoreRequest, RuntimeUserAnswersRequest, + RuntimeAgentModeSummary, RuntimeIpcOperation, RuntimeIpcOperationResult, + RuntimeSessionForkRequest, RuntimeSessionRenameRequest, RuntimeSessionRestoreRequest, + RuntimeUserAnswersRequest, }; pub use protocol::{ HealthResult, InitializeRequest, InitializeResult, RuntimeIpcCapabilities, RuntimeIpcError, diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index c7cfb06792..8901f9f7de 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -6,9 +6,10 @@ use bitfun_runtime_ports::{ AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRevertRequest, AgentSessionRevertResult, - AgentSessionSummary, AgentTurnCancellationRequest, AgentTurnCancellationResult, - AgentUserShellCommandRequest, AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, - AgentWorkspaceReferenceSearchResult, SessionTranscript, WorkspaceDiffSnapshot, + AgentSessionSummary, AgentSessionWorkspaceBinding, AgentTurnCancellationRequest, + AgentTurnCancellationResult, AgentUserShellCommandRequest, AgentWorkspaceReference, + AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, SessionTranscript, + WorkspaceDiffSnapshot, }; use serde::{Deserialize, Serialize}; @@ -44,6 +45,19 @@ pub struct RuntimeUserAnswersRequest { pub answers: serde_json::Value, } +/// Minimal host-owned main-agent catalog consumed by Shared TUI selectors. +/// Runtime generation keys and provider-specific source state never cross IPC. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeAgentModeSummary { + pub id: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + #[serde(default)] + pub is_external: bool, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde( tag = "operation", @@ -53,6 +67,10 @@ pub struct RuntimeUserAnswersRequest { )] pub enum RuntimeIpcOperation { Health, + ListAgentModes { + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + }, ListSessions { request: AgentSessionListRequest, }, @@ -144,6 +162,9 @@ impl RuntimeIpcOperation { pub fn session_id(&self) -> Option<&str> { match self { + Self::ListAgentModes { + session_id: Some(session_id), + } => Some(session_id), Self::RestoreSession { request } => Some(&request.session_id), Self::DeleteSession { session_id } => Some(session_id), Self::UpdateSessionMode { request } => Some(&request.session_id), @@ -167,6 +188,7 @@ impl RuntimeIpcOperation { | Self::RespondPermission { session_id, .. } => Some(session_id), Self::SubmitUserAnswers { request } => Some(&request.session_id), Self::Health + | Self::ListAgentModes { session_id: None } | Self::ListSessions { .. } | Self::CreateSession { .. } | Self::WorkspaceDiff => None, @@ -179,9 +201,14 @@ impl RuntimeIpcOperation { }; match self { - Self::Health | Self::ListSessions { .. } => { - RuntimeIpcOperationRules::new(None, false, false, false) + Self::Health + | Self::ListAgentModes { + session_id: std::option::Option::None, } + | Self::ListSessions { .. } => RuntimeIpcOperationRules::new(None, false, false, false), + Self::ListAgentModes { + session_id: Some(_), + } => RuntimeIpcOperationRules::new(CurrentController, false, false, false), Self::WorkspaceDiff => RuntimeIpcOperationRules::new(None, true, false, false), Self::CreateSession { .. } => RuntimeIpcOperationRules::new(None, true, true, true), Self::RestoreSession { .. } => { @@ -271,6 +298,9 @@ pub enum RuntimeIpcOperationResult { process_id: u32, }, Unit, + AgentModes { + modes: Vec, + }, Sessions { sessions: Vec, }, @@ -279,11 +309,13 @@ pub enum RuntimeIpcOperationResult { }, SessionRestored { session: AgentSessionSummary, + workspace_binding: AgentSessionWorkspaceBinding, transcript: SessionTranscript, pending_permissions: Vec, }, SessionForked { session: AgentSessionSummary, + workspace_binding: AgentSessionWorkspaceBinding, transcript: SessionTranscript, }, SessionReverted { diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index 9ba79b7777..3788cde22a 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs @@ -5,7 +5,7 @@ use crate::{RuntimeIpcOperation, RuntimeIpcOperationResult}; use bitfun_events::AgenticEventEnvelope; use bitfun_product_domains::tool_permissions::PermissionRequestEvent; -pub const PROTOCOL_VERSION: u32 = 15; +pub const PROTOCOL_VERSION: u32 = 16; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 6499aa6f05..295d8ae8ce 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -1,9 +1,9 @@ use crate::operation::RuntimeIpcSessionRequirement; use crate::{ - serialize_frame_with_limit, InitializeRequest, RuntimeIpcError, RuntimeIpcErrorCode, - RuntimeIpcFrame, RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeSessionForkRequest, - RuntimeSessionRenameRequest, RuntimeUserAnswersRequest, MAX_REQUEST_FRAME_BYTES, - PROTOCOL_VERSION, + serialize_frame_with_limit, InitializeRequest, RuntimeAgentModeSummary, RuntimeIpcError, + RuntimeIpcErrorCode, RuntimeIpcFrame, RuntimeIpcOperation, RuntimeIpcOperationResult, + RuntimeSessionForkRequest, RuntimeSessionRenameRequest, RuntimeUserAnswersRequest, + MAX_REQUEST_FRAME_BYTES, PROTOCOL_VERSION, }; use bitfun_product_domains::tool_permissions::PermissionReply; @@ -73,9 +73,64 @@ fn protocol_round_trips_reviewed_permission_and_user_input_operations() { } } +#[test] +fn protocol_round_trips_read_only_main_agent_catalog() { + assert_eq!(PROTOCOL_VERSION, 16); + let operation = RuntimeIpcOperation::ListAgentModes { + session_id: Some("session-1".to_string()), + }; + let result = RuntimeIpcOperationResult::AgentModes { + modes: vec![RuntimeAgentModeSummary { + id: "review".to_string(), + description: "Review the current workspace".to_string(), + model_id: Some("provider/model".to_string()), + is_external: true, + }], + }; + + let operation_json = + serde_json::to_value(&operation).expect("serialize mode catalog operation"); + assert_eq!( + operation_json, + json!({"operation": "list_agent_modes", "sessionId": "session-1"}) + ); + let decoded_operation: RuntimeIpcOperation = + serde_json::from_value(operation_json).expect("deserialize mode catalog operation"); + assert_eq!(decoded_operation, operation); + assert_eq!(decoded_operation.session_id(), Some("session-1")); + let rules = decoded_operation.rules(); + assert_eq!( + rules.session_requirement, + RuntimeIpcSessionRequirement::CurrentController + ); + assert!(!rules.requires_idle); + assert!(!rules.serializes_session_selection); + assert!(!rules.side_effecting); + + let result_json = serde_json::to_value(&result).expect("serialize mode catalog result"); + assert_eq!(result_json["result"], "agent_modes"); + assert_eq!(result_json["modes"][0]["id"], "review"); + assert_eq!(result_json["modes"][0]["modelId"], "provider/model"); + assert_eq!(result_json["modes"][0]["isExternal"], true); + let decoded_result: RuntimeIpcOperationResult = + serde_json::from_value(result_json).expect("deserialize mode catalog result"); + assert_eq!(decoded_result, result); + + let startup_operation = RuntimeIpcOperation::ListAgentModes { session_id: None }; + assert_eq!(startup_operation.session_id(), None); + assert_eq!( + startup_operation.rules().session_requirement, + RuntimeIpcSessionRequirement::None + ); + assert_eq!( + serde_json::to_value(startup_operation).expect("serialize startup mode catalog"), + json!({"operation": "list_agent_modes"}) + ); +} + #[test] fn protocol_round_trips_exact_turn_steering_without_replacing_turn_admission() { - assert_eq!(PROTOCOL_VERSION, 15); + assert_eq!(PROTOCOL_VERSION, 16); let operation = RuntimeIpcOperation::SteerTurn { request: AgentDialogSteerRequest { session_id: "session-1".to_string(), @@ -191,7 +246,7 @@ fn protocol_round_trips_root_scoped_lineage_operations() { #[test] fn protocol_round_trips_workspace_diff_as_a_read_only_workspace_operation() { - assert_eq!(PROTOCOL_VERSION, 15); + assert_eq!(PROTOCOL_VERSION, 16); let operation = RuntimeIpcOperation::WorkspaceDiff; let encoded = serde_json::to_value(&operation).expect("serialize workspace diff operation"); @@ -312,7 +367,7 @@ fn protocol_round_trips_the_reviewed_session_model_operation() { #[test] fn protocol_round_trips_the_current_session_rename_operation() { - assert_eq!(PROTOCOL_VERSION, 15); + assert_eq!(PROTOCOL_VERSION, 16); let operation = RuntimeIpcOperation::RenameSession { request: RuntimeSessionRenameRequest { diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs index de4f9bda64..11a780eedc 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -12,8 +12,9 @@ use bitfun_runtime_ports::{ AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionLineageCancellationRequest, AgentSessionLineageTranscriptRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRevertRequest, AgentSessionRevertResult, - AgentSessionSummary, AgentSubmissionSource, AgentUserShellCommandRequest, - DialogSubmissionPolicy, SessionTranscript, + AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSubmissionSource, + AgentUserShellCommandRequest, DialogSubmissionPolicy, SessionExecutionTarget, + SessionTranscript, }; use serde_json::Map; use std::path::Path; @@ -226,6 +227,7 @@ impl RuntimeIpcRequestHandler for FakeHandler { session_id: "session-fork".to_string(), messages: Vec::new(), }, + workspace_binding: workspace_binding(), }) } RuntimeIpcOperation::SubmitTurn { request } => { @@ -621,6 +623,18 @@ fn restored(session_id: &str) -> RuntimeIpcOperationResult { messages: Vec::new(), }, pending_permissions: Vec::new(), + workspace_binding: workspace_binding(), + } +} + +fn workspace_binding() -> AgentSessionWorkspaceBinding { + AgentSessionWorkspaceBinding { + workspace_id: Some("workspace-fixture".to_string()), + workspace_path: "/workspace".to_string(), + project_workspace_path: Some("/workspace".to_string()), + execution_target: Some(SessionExecutionTarget::local("/workspace")), + remote_connection_id: None, + remote_ssh_host: None, } } @@ -913,6 +927,45 @@ async fn session_switching_is_exclusive_and_disconnect_releases_control() { server.finish().await; } +#[tokio::test] +async fn session_scoped_agent_catalog_requires_the_current_controller() { + let server = TestServer::start(server_config(), Arc::new(FakeHandler::default())).await; + let mut client = server.connect("catalog-controller").await; + + expect_response( + &mut client, + 2, + RuntimeIpcOperation::ListAgentModes { session_id: None }, + ) + .await; + expect_error( + &mut client, + 3, + RuntimeIpcOperation::ListAgentModes { + session_id: Some("session-a".to_string()), + }, + RuntimeIpcErrorCode::ControllerRequired, + ) + .await; + expect_response( + &mut client, + 4, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + expect_response( + &mut client, + 5, + RuntimeIpcOperation::ListAgentModes { + session_id: Some("session-a".to_string()), + }, + ) + .await; + + drop(client); + server.finish().await; +} + #[tokio::test] async fn successful_fork_atomically_transfers_control_and_releases_the_source() { let server = TestServer::start(server_config(), Arc::new(FakeHandler::default())).await; diff --git a/src/crates/adapters/claude-code-adapter/src/agent_source.rs b/src/crates/adapters/claude-code-adapter/src/agent_source.rs index eee4896c59..db8778c581 100644 --- a/src/crates/adapters/claude-code-adapter/src/agent_source.rs +++ b/src/crates/adapters/claude-code-adapter/src/agent_source.rs @@ -569,7 +569,10 @@ fn materialize_definition( display_name: logical_id, description, prompt: SecretText::new(winner.prompt.clone()), - mode: ExternalSubagentMode::Subagent, + // Claude Code uses the same agent definition for delegated work and + // whole-session `--agent` selection. Keep that role fact in the + // provider-neutral definition and let Product Assembly project it. + mode: ExternalSubagentMode::All, disabled: false, hidden: false, requested_model, diff --git a/src/crates/adapters/claude-code-adapter/tests/subagent_source.rs b/src/crates/adapters/claude-code-adapter/tests/subagent_source.rs index 7383d1e139..e8b651cc45 100644 --- a/src/crates/adapters/claude-code-adapter/tests/subagent_source.rs +++ b/src/crates/adapters/claude-code-adapter/tests/subagent_source.rs @@ -3,7 +3,7 @@ use bitfun_product_domains::external_sources::{ ExecutionDomainId, ExternalSourceContext, ExternalSourceScope, }; use bitfun_product_domains::external_subagents::{ - ExternalSubagentCompatibilityState, ExternalSubagentDiscoveryInput, + ExternalSubagentCompatibilityState, ExternalSubagentDiscoveryInput, ExternalSubagentMode, ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, ExternalSubagentSourceProvider, }; @@ -91,6 +91,11 @@ fn nearest_project_agent_overrides_user_agent_without_field_merge() { assert_eq!(definition.description, "Nearest"); assert_eq!(definition.prompt.expose(), "Nearest prompt"); assert_eq!(definition.provenance.len(), 3); + assert_eq!( + definition.mode, + ExternalSubagentMode::All, + "Claude Code agent files can be selected for a main session or delegated as a subagent" + ); assert_eq!( definition.requested_model, ExternalSubagentModelRequest::Reference { diff --git a/src/crates/adapters/opencode-adapter/src/agent_source.rs b/src/crates/adapters/opencode-adapter/src/agent_source.rs index 9f95deb604..da383aa8cb 100644 --- a/src/crates/adapters/opencode-adapter/src/agent_source.rs +++ b/src/crates/adapters/opencode-adapter/src/agent_source.rs @@ -771,14 +771,8 @@ fn materialize_definition( let display_name = logical_id.clone(); let mode = match string_field(fields, "mode", &mut invalid).as_deref() { Some("subagent") => ExternalSubagentMode::Subagent, - Some("all") | None => { - degraded.push("opencode_primary_facet_not_imported".to_string()); - ExternalSubagentMode::All - } - Some("primary") => { - blocked.push("opencode_primary_agent_not_imported".to_string()); - ExternalSubagentMode::Primary - } + Some("all") | None => ExternalSubagentMode::All, + Some("primary") => ExternalSubagentMode::Primary, Some(_) => { invalid.push("opencode_agent_mode_invalid".to_string()); ExternalSubagentMode::Subagent diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs index 1cedff086f..bbd12953cb 100644 --- a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs +++ b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs @@ -1278,8 +1278,12 @@ fn safe_subset_is_fail_closed_and_default_tools_are_explicit() { ); assert_eq!( find("primaryOnly").compatibility, - ExternalSubagentCompatibilityState::Blocked + ExternalSubagentCompatibilityState::Blocked, + "the source-level ambient permission remains unsupported" ); + assert!(!find("primaryOnly") + .diagnostic_codes + .contains(&"opencode_primary_agent_not_imported".to_string())); assert_eq!( find("sampling").compatibility, ExternalSubagentCompatibilityState::Blocked @@ -1290,6 +1294,48 @@ fn safe_subset_is_fail_closed_and_default_tools_are_explicit() { assert!(!debug.contains("do-not-leak-unknown")); } +#[test] +fn primary_and_all_roles_are_static_agent_profiles_without_role_degradation() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "primary": { "prompt": "Lead the session", "mode": "primary", "tools": { "read": true } }, + "both": { "prompt": "Lead or assist", "mode": "all", "tools": { "read": true } }, + "helper": { "prompt": "Assist only", "mode": "subagent", "tools": { "read": true } } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let find = |id: &str| { + snapshot + .definitions + .iter() + .find(|definition| definition.logical_id == id) + .unwrap() + }; + + assert_eq!(find("primary").mode, ExternalSubagentMode::Primary); + assert_eq!(find("both").mode, ExternalSubagentMode::All); + assert_eq!(find("helper").mode, ExternalSubagentMode::Subagent); + for id in ["primary", "both", "helper"] { + assert_eq!( + find(id).compatibility, + ExternalSubagentCompatibilityState::Ready + ); + assert!(!find(id) + .diagnostic_codes + .iter() + .any(|code| code.contains("primary_facet") || code.contains("primary_agent"))); + } +} + #[test] fn markdown_agent_directories_are_supported_and_legacy_modes_are_visible_but_blocked() { let temp = TempDir::new().unwrap(); diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs index 66952c2462..ec36963e6d 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs @@ -10,7 +10,7 @@ use bitfun_runtime_ports::PermissionConstraintLayer; /// Immutable, generation-keyed projection of an approved external definition. /// Prompt text remains backend-only and the type deliberately implements no /// serialization or content-bearing debug representation. -pub(crate) struct ExternalProvidedSubagent { +pub(crate) struct ExternalProvidedAgent { runtime_key: String, name: String, description: String, @@ -21,7 +21,7 @@ pub(crate) struct ExternalProvidedSubagent { behavior_version: String, } -impl ExternalProvidedSubagent { +impl ExternalProvidedAgent { pub(crate) fn new( runtime_key: String, name: String, @@ -46,7 +46,7 @@ impl ExternalProvidedSubagent { } #[async_trait] -impl Agent for ExternalProvidedSubagent { +impl Agent for ExternalProvidedAgent { fn as_any(&self) -> &dyn std::any::Any { self } @@ -68,10 +68,7 @@ impl Agent for ExternalProvidedSubagent { } fn system_prompt_cache_identity(&self, _model_name: Option<&str>) -> SystemPromptCacheIdentity { - SystemPromptCacheIdentity::new(format!( - "external_subagent_behavior:{}", - self.behavior_version - )) + SystemPromptCacheIdentity::new(format!("external_agent_behavior:{}", self.behavior_version)) } async fn build_prompt(&self, context: &PromptBuilderContext) -> BitFunResult { diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 1cf1e44b5f..7df3ce2131 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -23,7 +23,7 @@ pub use bitfun_agent_runtime::custom_agent::{ }; use bitfun_runtime_ports::PermissionConstraintLayer; pub use definitions::custom::{CustomMode, CustomSubagent, CustomSubagentKind}; -pub(crate) use definitions::external::ExternalProvidedSubagent; +pub(crate) use definitions::external::ExternalProvidedAgent; pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent}; pub use definitions::modes::{ AgenticMode, ClawMode, CoworkMode, DebugMode, DeepResearchMode, MultitaskMode, PlanMode, @@ -52,8 +52,9 @@ pub use registry::visibility::{ }; pub use registry::{ get_agent_registry, AgentRegistry, CustomAgentDetail, CustomSubagentDetail, - ExternalSubagentGenerationLease, ExternalSubagentInvocationBinding, - ExternalSubagentModelBinding, ExternalSubagentRegistration, ExternalSubagentRoute, + ExternalPrimaryAgentTurnBinding, ExternalSubagentGenerationLease, + ExternalSubagentInvocationBinding, ExternalSubagentModelBinding, ExternalSubagentRegistration, + ExternalSubagentRoute, }; use std::any::Any; diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index 07a084ab06..df1badffd4 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -1,9 +1,13 @@ use super::types::{AgentCategory, AgentEntry, AgentInfo, AgentSource, SubAgentSource}; use super::AgentRegistry; use crate::agentic::agents::{Agent, SubagentVisibilityPolicy}; +use crate::agentic::workspace::canonical_local_workspace_path; use bitfun_agent_runtime::prompt_cache::prompt_cache_scope_key; -use bitfun_core_types::{SessionContinuationPolicy, SessionModelBindingPolicy}; +use bitfun_core_types::{ + SessionAgentRouteOwner, SessionContinuationPolicy, SessionModelBindingPolicy, +}; use bitfun_product_domains::external_sources::EcosystemId; +use bitfun_product_domains::external_subagents::ExternalSubagentMode; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock, Weak}; @@ -50,9 +54,25 @@ pub struct ExternalSubagentRegistration { pub provider_label: String, pub model_binding: ExternalSubagentModelBinding, pub hidden: bool, + pub mode: ExternalSubagentMode, pub agent: Arc, } +#[derive(Clone, Copy)] +enum ExternalAgentProjection { + Primary, + Subagent, +} + +impl ExternalAgentProjection { + fn is_supported_by(self, mode: ExternalSubagentMode) -> bool { + match self { + Self::Primary => mode.supports_primary(), + Self::Subagent => mode.supports_subagent(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExternalSubagentRoute { Local, @@ -142,6 +162,7 @@ impl ExternalSubagentRegistryState { self: &Arc, runtime_key: &str, expected_ecosystem_id: Option<&EcosystemId>, + projection: ExternalAgentProjection, ) -> Option { let mut generations = self.write_generations(); let entry = generations.get_mut(runtime_key)?; @@ -150,6 +171,9 @@ impl ExternalSubagentRegistryState { { return None; } + if !projection.is_supported_by(entry.registration.mode) { + return None; + } entry.lease_count = entry.lease_count.saturating_add(1); Some(ExternalSubagentInvocationBinding { runtime_agent_key: runtime_key.to_string(), @@ -166,7 +190,30 @@ impl ExternalSubagentRegistryState { } fn acquire(self: &Arc, runtime_key: &str) -> Option { - self.acquire_matching(runtime_key, None) + self.acquire_matching(runtime_key, None, ExternalAgentProjection::Subagent) + } + + fn acquire_primary( + self: &Arc, + runtime_key: &str, + ) -> Option { + let mut generations = self.write_generations(); + let entry = generations.get_mut(runtime_key)?; + if !entry.registration.mode.supports_primary() { + return None; + } + entry.lease_count = entry.lease_count.saturating_add(1); + let model_binding = entry.registration.model_binding.clone(); + Some(ExternalPrimaryAgentTurnBinding { + runtime_agent_key: runtime_key.to_string(), + model_binding: Some(model_binding.clone()), + route_owner: SessionAgentRouteOwner::External, + lease: Some(ExternalSubagentGenerationLease { + state: Arc::downgrade(self), + runtime_key: runtime_key.to_string(), + model_binding, + }), + }) } fn release(&self, runtime_key: &str) { @@ -231,6 +278,13 @@ pub struct ExternalSubagentInvocationBinding { pub lease: Option, } +pub struct ExternalPrimaryAgentTurnBinding { + pub runtime_agent_key: String, + pub model_binding: Option, + pub route_owner: SessionAgentRouteOwner, + pub lease: Option, +} + impl AgentRegistry { /// Returns whether the logical id is owned by an external route in the /// requested workspace. `Unavailable` remains externally owned so a @@ -249,8 +303,9 @@ impl AgentRegistry { ) }; workspace_root.is_some_and(|workspace| { + let workspace = canonical_local_workspace_path(workspace); routes - .get(workspace) + .get(&workspace) .and_then(|workspace_routes| workspace_routes.get(&logical_key)) .is_some_and(is_external) }) @@ -262,6 +317,7 @@ impl AgentRegistry { registrations: Vec, routes: BTreeMap, ) { + let workspace_root = canonical_local_workspace_path(workspace_root); { let mut generations = self.external_subagents.write_generations(); for registration in registrations { @@ -294,7 +350,7 @@ impl AgentRegistry { let previous = self .external_subagents .read_routes() - .get(workspace_root) + .get(&workspace_root) .cloned() .unwrap_or_default(); // An active external implementation disappearing must never expose a @@ -313,14 +369,15 @@ impl AgentRegistry { } self.external_subagents .write_routes() - .insert(workspace_root.to_path_buf(), routes); + .insert(workspace_root, routes); self.external_subagents.prune_unrouted_generations(); } pub fn release_external_subagent_workspace(&self, workspace_root: &Path) { + let workspace_root = canonical_local_workspace_path(workspace_root); self.external_subagents .write_routes() - .remove(workspace_root); + .remove(&workspace_root); self.external_subagents.prune_unrouted_generations(); } @@ -333,10 +390,11 @@ impl AgentRegistry { let logical_key = normalize_external_logical_id(logical_id); if external_sources_supported { if let Some(workspace_root) = workspace_root { + let workspace_key = canonical_local_workspace_path(workspace_root); if let Some(route) = self .external_subagents .read_routes() - .get(workspace_root) + .get(&workspace_key) .and_then(|routes| routes.get(&logical_key)) .cloned() { @@ -356,6 +414,51 @@ impl AgentRegistry { .map(|entry| local_binding(logical_id, entry.agent.id())) } + /// Resolve a user-facing main-agent id to the exact generation that owns + /// the next turn. The returned lease keeps prompt, tools, permissions, and + /// model metadata stable until that turn settles. + pub fn resolve_primary_agent_for_turn( + &self, + logical_id: &str, + workspace_root: Option<&Path>, + external_sources_supported: bool, + expected_owner: Option, + ) -> Option { + let logical_key = normalize_external_logical_id(logical_id); + if external_sources_supported { + if let Some(workspace_root) = workspace_root { + let workspace_key = canonical_local_workspace_path(workspace_root); + if let Some(route) = self + .external_subagents + .read_routes() + .get(&workspace_key) + .and_then(|routes| routes.get(&logical_key)) + .cloned() + { + let binding = match route { + ExternalSubagentRoute::Local => self + .find_agent_entry(logical_id, Some(workspace_root)) + .filter(|entry| entry.category == AgentCategory::Mode) + .map(|entry| local_primary_binding(entry.agent.id())), + ExternalSubagentRoute::External(runtime_key) => { + self.external_subagents.acquire_primary(&runtime_key) + } + ExternalSubagentRoute::Unavailable => None, + }; + return binding.filter(|binding| { + expected_owner.is_none_or(|owner| binding.route_owner == owner) + }); + } + } + } + if expected_owner == Some(SessionAgentRouteOwner::External) { + return None; + } + self.find_agent_entry(logical_id, workspace_root) + .filter(|entry| entry.category == AgentCategory::Mode) + .map(|entry| local_primary_binding(entry.agent.id())) + } + /// Resolve only the currently approved external route for an exact /// ecosystem. Command delegation must never fall back to a same-name local /// agent or cross an ecosystem boundary after the command was expanded. @@ -366,17 +469,22 @@ impl AgentRegistry { workspace_root: Option<&Path>, ) -> Option { let workspace_root = workspace_root?; + let workspace_key = canonical_local_workspace_path(workspace_root); let logical_key = normalize_external_logical_id(logical_id); let route = self .external_subagents .read_routes() - .get(workspace_root) + .get(&workspace_key) .and_then(|routes| routes.get(&logical_key)) .cloned()?; match route { - ExternalSubagentRoute::External(runtime_key) => self - .external_subagents - .acquire_matching(&runtime_key, Some(ecosystem_id)), + ExternalSubagentRoute::External(runtime_key) => { + self.external_subagents.acquire_matching( + &runtime_key, + Some(ecosystem_id), + ExternalAgentProjection::Subagent, + ) + } ExternalSubagentRoute::Local | ExternalSubagentRoute::Unavailable => None, } } @@ -386,10 +494,11 @@ impl AgentRegistry { workspace_root: &Path, mut local: Vec, ) -> Vec { + let workspace_root = canonical_local_workspace_path(workspace_root); let routes = self .external_subagents .read_routes() - .get(workspace_root) + .get(&workspace_root) .cloned() .unwrap_or_default(); let generations = self.external_subagents.read_generations(); @@ -404,10 +513,47 @@ impl AgentRegistry { let Some(entry) = generations.get(&runtime_key) else { continue; }; - if entry.registration.hidden { + if entry.registration.hidden || !entry.registration.mode.supports_subagent() { continue; } - local.push(external_agent_info(entry)); + local.push(external_agent_info( + entry, + ExternalAgentProjection::Subagent, + )); + } + } + } + local + } + + pub(super) fn apply_external_routes_to_modes( + &self, + workspace_root: &Path, + mut local: Vec, + ) -> Vec { + let workspace_root = canonical_local_workspace_path(workspace_root); + let routes = self + .external_subagents + .read_routes() + .get(&workspace_root) + .cloned() + .unwrap_or_default(); + let generations = self.external_subagents.read_generations(); + for (logical_id, route) in routes { + match route { + ExternalSubagentRoute::Local => {} + ExternalSubagentRoute::Unavailable => { + local.retain(|agent| normalize_external_logical_id(&agent.id) != logical_id); + } + ExternalSubagentRoute::External(runtime_key) => { + local.retain(|agent| normalize_external_logical_id(&agent.id) != logical_id); + let Some(entry) = generations.get(&runtime_key) else { + continue; + }; + if entry.registration.hidden || !entry.registration.mode.supports_primary() { + continue; + } + local.push(external_agent_info(entry, ExternalAgentProjection::Primary)); } } } @@ -430,7 +576,19 @@ fn local_binding(logical_id: &str, runtime_agent_key: &str) -> ExternalSubagentI } } -fn external_agent_info(entry: &ExternalSubagentGenerationEntry) -> AgentInfo { +fn local_primary_binding(runtime_agent_key: &str) -> ExternalPrimaryAgentTurnBinding { + ExternalPrimaryAgentTurnBinding { + runtime_agent_key: runtime_agent_key.to_string(), + model_binding: None, + route_owner: SessionAgentRouteOwner::Local, + lease: None, + } +} + +fn external_agent_info( + entry: &ExternalSubagentGenerationEntry, + projection: ExternalAgentProjection, +) -> AgentInfo { let agent = entry.registration.agent.as_ref(); let default_tools = agent.default_tools(); AgentInfo { @@ -458,7 +616,8 @@ fn external_agent_info(entry: &ExternalSubagentGenerationEntry) -> AgentInfo { override_state: None, state_reason: None, source: AgentSource::External, - subagent_source: Some(SubAgentSource::External), + subagent_source: matches!(projection, ExternalAgentProjection::Subagent) + .then_some(SubAgentSource::External), path: None, model: entry .registration @@ -466,8 +625,9 @@ fn external_agent_info(entry: &ExternalSubagentGenerationEntry) -> AgentInfo { .fixed_model_id() .map(str::to_string), model_is_explicit: Some(entry.registration.model_binding.fixed_model_id().is_some()), - visibility: Some(SubagentVisibilityPolicy::public().summary()), + visibility: matches!(projection, ExternalAgentProjection::Subagent) + .then(|| SubagentVisibilityPolicy::public().summary()), external_provider_label: Some(entry.registration.provider_label.clone()), - supports_follow_up: false, + supports_follow_up: matches!(projection, ExternalAgentProjection::Primary), } } diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index acfedb73a0..d0e7e1e141 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -24,8 +24,9 @@ use std::sync::{Arc, OnceLock}; pub(crate) use external::external_subagent_runtime_key; pub use external::{ - ExternalSubagentGenerationLease, ExternalSubagentInvocationBinding, - ExternalSubagentModelBinding, ExternalSubagentRegistration, ExternalSubagentRoute, + ExternalPrimaryAgentTurnBinding, ExternalSubagentGenerationLease, + ExternalSubagentInvocationBinding, ExternalSubagentModelBinding, ExternalSubagentRegistration, + ExternalSubagentRoute, }; /// Full file-backed custom agent definition for editing. diff --git a/src/crates/assembly/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs index 5f423d4d07..6e2622b460 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -17,12 +17,11 @@ use std::collections::HashSet; use std::path::Path; impl AgentRegistry { - /// Return every effective local subagent definition that can participate in - /// product-level external-source conflict resolution. This deliberately - /// ignores presentation visibility: a hidden but explicitly addressable - /// local agent must still block a same-name external route from being - /// selected silently. - pub(crate) async fn get_local_subagents_for_external_resolution( + /// Return every effective local agent definition that can participate in + /// product-level external-source conflict resolution. Main-agent modes and + /// subagents share one logical id namespace in external ecosystems, so a + /// same-name local definition in either role must prevent silent takeover. + pub(crate) async fn get_local_agents_for_external_resolution( &self, workspace_root: Option<&Path>, ) -> Vec { @@ -140,6 +139,17 @@ impl AgentRegistry { /// get all mode agent information, used for frontend mode selector etc. pub async fn get_modes_info(&self) -> Vec { + self.get_modes_info_for_workspace(None, false).await + } + + /// Return main-agent profiles for one execution workspace. External + /// profiles are a workspace projection over the same generation registry + /// used by Task; remote/read-only hosts must keep this disabled. + pub async fn get_modes_info_for_workspace( + &self, + workspace_root: Option<&Path>, + external_sources_supported: bool, + ) -> Vec { self.ensure_user_custom_agents_loaded().await; let map = self.read_agents(); let mut result: Vec = map @@ -148,6 +158,11 @@ impl AgentRegistry { .map(AgentInfo::from_agent_entry) .collect(); drop(map); + if external_sources_supported { + if let Some(workspace_root) = workspace_root { + result = self.apply_external_routes_to_modes(workspace_root, result); + } + } result.sort_by(|a, b| { let a_rank = match a.source { AgentSource::Builtin => mode_presentation_rank(&a.id), @@ -421,9 +436,16 @@ fn local_conflict_info( project_overrides: Option<&crate::service::config::types::AgentSubagentOverrideConfig>, user_overrides: &crate::service::config::types::AgentSubagentOverrideConfig, ) -> Option { - if entry.category != AgentCategory::SubAgent || entry.source == AgentSource::External { + if !matches!( + entry.category, + AgentCategory::Mode | AgentCategory::SubAgent + ) || entry.source == AgentSource::External + { return None; } + if entry.category == AgentCategory::Mode { + return Some(AgentInfo::from_agent_entry(entry)); + } let availability = resolve_availability(entry, parent_agent_type, project_overrides, user_overrides); if !availability.effective_enabled { diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index e40c2a1c62..ee6934f8f8 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -10,6 +10,7 @@ use crate::agentic::agents::registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, }; use crate::agentic::agents::{resolve_mode_config_profile_id, Agent, UserContextPolicy}; +use crate::agentic::workspace::session_execution_workspace_root; use crate::service::config::types::AgentSubagentOverrideState; use async_trait::async_trait; use bitfun_agent_runtime::custom_agent::{ @@ -17,7 +18,9 @@ use bitfun_agent_runtime::custom_agent::{ CustomAgentKind, CustomAgentLevel, }; use bitfun_agent_runtime::sdk::{RuntimeAgentRegistry, RuntimeAgentRegistryQuery}; +use bitfun_agent_runtime::session::SessionConfig; use bitfun_product_domains::external_sources::EcosystemId; +use bitfun_product_domains::external_subagents::ExternalSubagentMode; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -1168,6 +1171,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( configuration_fingerprint: "model-config-v1".to_string(), }, hidden: false, + mode: ExternalSubagentMode::All, agent: agent_v1, }], [( @@ -1212,6 +1216,24 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( assert_eq!(projected.model.as_deref(), Some("inherit")); assert_eq!(projected.model_is_explicit, Some(true)); assert!(!projected.supports_follow_up); + let primary_modes = registry + .get_modes_info_for_workspace(Some(&workspace), true) + .await; + let primary = primary_modes + .iter() + .find(|agent| agent.id == "Explore") + .expect("an all-role external definition is also projected as a main agent"); + assert_eq!(primary.source, AgentSource::External); + assert!(primary.supports_follow_up); + let primary_binding = registry + .resolve_primary_agent_for_turn("Explore", Some(&workspace), true, None) + .expect("the logical main-agent id resolves to an immutable generation"); + assert_eq!(primary_binding.runtime_agent_key, runtime_v1); + assert_eq!( + primary_binding.route_owner, + bitfun_core_types::SessionAgentRouteOwner::External + ); + drop(primary_binding); assert!(registry.is_external_subagent_route("Explore", Some(&workspace))); assert!(registry.is_external_subagent_route("EXPLORE", Some(&workspace))); assert!(!registry.is_external_subagent_route("Explore", None)); @@ -1272,6 +1294,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( configuration_fingerprint: "model-config-v2".to_string(), }, hidden: false, + mode: ExternalSubagentMode::All, agent: agent_v2, }], [( @@ -1313,3 +1336,256 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( drop(binding); assert!(registry.get_agent(runtime_v1, Some(&workspace)).is_none()); } + +#[tokio::test] +async fn external_routes_use_one_canonical_workspace_identity_for_all_operations() { + let registry = AgentRegistry::new(); + let workspace = tempfile::tempdir().expect("workspace"); + let alias_component = workspace.path().join("alias-component"); + std::fs::create_dir(&alias_component).expect("alias component"); + let workspace_alias = alias_component.join(".."); + let runtime_key = "external::canonical-workspace"; + registry.install_external_subagent_routes( + workspace.path(), + vec![ExternalSubagentRegistration { + runtime_key: runtime_key.to_string(), + logical_id: "canonical-profile".to_string(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + provider_label: "OpenCode".to_string(), + model_binding: super::ExternalSubagentModelBinding::InheritParent, + hidden: false, + mode: ExternalSubagentMode::Primary, + agent: Arc::new(TestAgent { + id: runtime_key.to_string(), + }), + }], + [( + "canonical-profile".to_string(), + ExternalSubagentRoute::External(runtime_key.to_string()), + )] + .into_iter() + .collect(), + ); + + assert!(registry.is_external_subagent_route("canonical-profile", Some(&workspace_alias))); + let binding = registry + .resolve_primary_agent_for_turn("canonical-profile", Some(&workspace_alias), true, None) + .expect("alias path should resolve the installed external generation"); + assert_eq!(binding.runtime_agent_key, runtime_key); + drop(binding); + assert!(registry + .get_modes_info_for_workspace(Some(&workspace_alias), true) + .await + .iter() + .any(|agent| agent.id == "canonical-profile")); + + registry.release_external_subagent_workspace(&workspace_alias); + assert!(!registry.is_external_subagent_route("canonical-profile", Some(workspace.path()))); +} + +#[test] +fn persisted_external_owner_never_falls_back_to_a_same_name_local_mode() { + let registry = AgentRegistry::new(); + + assert!(registry + .resolve_primary_agent_for_turn( + "agentic", + Some(Path::new("C:/workspace/restarted-external-owner")), + true, + Some(bitfun_core_types::SessionAgentRouteOwner::External), + ) + .is_none()); + let local = registry + .resolve_primary_agent_for_turn( + "agentic", + Some(Path::new("C:/workspace/legacy-local-owner")), + true, + Some(bitfun_core_types::SessionAgentRouteOwner::Local), + ) + .expect("legacy local sessions keep their local route"); + assert_eq!( + local.route_owner, + bitfun_core_types::SessionAgentRouteOwner::Local + ); +} + +#[tokio::test] +async fn external_agent_role_controls_main_and_task_projection() { + let registry = AgentRegistry::new(); + let workspace = PathBuf::from("C:/workspace/external-agent-roles"); + let logical_id = "external-role-profile"; + let registration = |runtime_key: &str, mode| ExternalSubagentRegistration { + runtime_key: runtime_key.to_string(), + logical_id: logical_id.to_string(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + provider_label: "OpenCode".to_string(), + model_binding: super::ExternalSubagentModelBinding::InheritParent, + hidden: false, + mode, + agent: Arc::new(TestAgent { + id: runtime_key.to_string(), + }), + }; + let route = |runtime_key: &str| { + [( + logical_id.to_string(), + ExternalSubagentRoute::External(runtime_key.to_string()), + )] + .into_iter() + .collect() + }; + + registry.install_external_subagent_routes( + &workspace, + vec![registration( + "external::primary", + ExternalSubagentMode::Primary, + )], + route("external::primary"), + ); + assert!(registry + .get_modes_info_for_workspace(Some(&workspace), true) + .await + .iter() + .any(|agent| agent.id == logical_id)); + assert!(!registry + .get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some("agentic"), + workspace_root: Some(&workspace), + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + external_sources_supported: true, + }) + .await + .iter() + .any(|agent| agent.id == logical_id)); + + registry.install_external_subagent_routes( + &workspace, + vec![registration( + "external::subagent", + ExternalSubagentMode::Subagent, + )], + route("external::subagent"), + ); + assert!(!registry + .get_modes_info_for_workspace(Some(&workspace), true) + .await + .iter() + .any(|agent| agent.id == logical_id)); + assert!(registry + .get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some("agentic"), + workspace_root: Some(&workspace), + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + external_sources_supported: true, + }) + .await + .iter() + .any(|agent| agent.id == logical_id)); +} + +#[test] +fn persisted_primary_route_owner_rejects_same_name_route_takeover() { + let registry = AgentRegistry::new(); + let workspace = PathBuf::from("D:/workspace/owner-takeover"); + let logical_id = "agentic"; + let runtime_key = "external::agentic"; + registry.install_external_subagent_routes( + &workspace, + vec![ExternalSubagentRegistration { + runtime_key: runtime_key.to_string(), + logical_id: logical_id.to_string(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + provider_label: "OpenCode".to_string(), + model_binding: super::ExternalSubagentModelBinding::InheritParent, + hidden: false, + mode: ExternalSubagentMode::Primary, + agent: Arc::new(TestAgent { + id: runtime_key.to_string(), + }), + }], + [( + logical_id.to_string(), + ExternalSubagentRoute::External(runtime_key.to_string()), + )] + .into_iter() + .collect(), + ); + + assert!(registry + .resolve_primary_agent_for_turn( + logical_id, + Some(&workspace), + true, + Some(bitfun_core_types::SessionAgentRouteOwner::Local), + ) + .is_none()); + + registry.install_external_subagent_routes( + &workspace, + Vec::new(), + [(logical_id.to_string(), ExternalSubagentRoute::Local)] + .into_iter() + .collect(), + ); + assert!(registry + .resolve_primary_agent_for_turn( + logical_id, + Some(&workspace), + true, + Some(bitfun_core_types::SessionAgentRouteOwner::External), + ) + .is_none()); +} + +#[test] +fn external_primary_route_follows_the_session_execution_worktree() { + let registry = AgentRegistry::new(); + let project = PathBuf::from("D:/workspace/project"); + let worktree = PathBuf::from("D:/workspace/worktrees/feature"); + let logical_id = "workspace-profile"; + let registration = |runtime_key: &str| ExternalSubagentRegistration { + runtime_key: runtime_key.to_string(), + logical_id: logical_id.to_string(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + provider_label: "OpenCode".to_string(), + model_binding: super::ExternalSubagentModelBinding::InheritParent, + hidden: false, + mode: ExternalSubagentMode::Primary, + agent: Arc::new(TestAgent { + id: runtime_key.to_string(), + }), + }; + let route = |runtime_key: &str| { + [( + logical_id.to_string(), + ExternalSubagentRoute::External(runtime_key.to_string()), + )] + .into_iter() + .collect() + }; + registry.install_external_subagent_routes( + &project, + vec![registration("external::project")], + route("external::project"), + ); + registry.install_external_subagent_routes( + &worktree, + vec![registration("external::worktree")], + route("external::worktree"), + ); + + let config = SessionConfig { + workspace_path: Some(worktree.to_string_lossy().into_owned()), + project_workspace_path: Some(project.to_string_lossy().into_owned()), + ..SessionConfig::default() + }; + let route_root = session_execution_workspace_root(&config).expect("execution root"); + let binding = registry + .resolve_primary_agent_for_turn(logical_id, Some(route_root), true, None) + .expect("worktree route should resolve"); + + assert_eq!(binding.runtime_agent_key, "external::worktree"); +} diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 144611923e..0254be7e3c 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -16,8 +16,8 @@ use crate::agentic::agents::{get_agent_registry, ExternalSubagentModelBinding}; use crate::agentic::context_profile::ContextProfilePolicy; use crate::agentic::core::{ InternalReminderKind, Message, MessageContent, MessageSemanticKind, ProcessingPhase, Session, - SessionConfig, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, SessionState, - SessionSummary, ToolCall, ToolResult, TurnStats, + SessionAgentRouteOwner, SessionConfig, SessionContinuationPolicy, SessionKind, + SessionModelBindingPolicy, SessionState, SessionSummary, ToolCall, ToolResult, TurnStats, }; use crate::agentic::events::{ AgenticEvent, DeepReviewQueueState, EventPriority, EventQueue, EventRouter, EventSubscriber, @@ -198,6 +198,31 @@ fn snapshot_normal_session_model(config: &mut SessionConfig, defaults: &AgentMod .or_else(|| Some(AgentModelDefaultsConfig::default().mode)); } +/// Apply an external primary profile's fixed model as a creation-time default. +/// An explicit user selection always wins; inherited bindings continue through +/// the existing product default path. +fn apply_primary_agent_model_default( + config: &mut SessionConfig, + binding: Option<&ExternalSubagentModelBinding>, +) { + let has_explicit_model = config + .model_id + .as_deref() + .map(str::trim) + .is_some_and(|model_id| { + !model_id.is_empty() + && !model_id.eq_ignore_ascii_case("auto") + && !model_id.eq_ignore_ascii_case("default") + }); + if has_explicit_model { + return; + } + + if let Some(model_id) = binding.and_then(ExternalSubagentModelBinding::fixed_model_id) { + config.model_id = Some(model_id.to_string()); + } +} + #[cfg(test)] tokio::task_local! { static TEST_AGENT_MODEL_DEFAULTS: AgentModelDefaultsConfig; @@ -1370,6 +1395,106 @@ impl ConversationCoordinator { } } + async fn resolve_primary_agent_for_workspace( + agent_type: &str, + workspace_root: Option<&Path>, + external_sources_supported: bool, + expected_owner: Option, + ) -> BitFunResult { + let registry = get_agent_registry(); + registry.load_custom_agents(workspace_root).await; + let local_binding = registry.resolve_primary_agent_for_turn( + agent_type, + workspace_root, + false, + expected_owner, + ); + + if !external_sources_supported { + return local_binding.ok_or_else(|| { + BitFunError::Validation(format!("Unknown session mode: {agent_type}")) + }); + } + + if let Err(error) = + crate::external_sources::ensure_external_source_workspace_snapshot(workspace_root).await + { + if let Some(external_binding) = registry.resolve_primary_agent_for_turn( + agent_type, + workspace_root, + true, + expected_owner, + ) { + warn!( + "External agent source discovery failed; continuing with the existing resolved route: agent_type={}, route_owner={:?}, error_category={}", + agent_type, + external_binding.route_owner, + crate::external_sources::external_integration_error_code(&error), + ); + return Ok(external_binding); + } + if expected_owner == Some(SessionAgentRouteOwner::External) + || registry.is_external_subagent_route(agent_type, workspace_root) + { + return Err(BitFunError::Validation(format!( + "candidate_unavailable: external main agent {agent_type} could not be refreshed" + ))); + } + if let Some(local_binding) = local_binding { + warn!( + "External agent source discovery failed; continuing with local mode: agent_type={}, error_category={}", + agent_type, + crate::external_sources::external_integration_error_code(&error), + ); + return Ok(local_binding); + } + return Err(BitFunError::Service(format!( + "External agent source discovery failed: {error}" + ))); + } + + registry + .resolve_primary_agent_for_turn( + agent_type, + workspace_root, + true, + expected_owner, + ) + .ok_or_else(|| { + if expected_owner == Some(SessionAgentRouteOwner::External) + || registry.is_external_subagent_route(agent_type, workspace_root) + { + BitFunError::Validation(format!( + "candidate_unavailable: external main agent {agent_type} changed before the turn could start" + )) + } else { + BitFunError::Validation(format!("Unknown session mode: {agent_type}")) + } + }) + } + + async fn resolve_session_primary_agent( + session: &Session, + agent_type: &str, + workspace: &Option, + ) -> BitFunResult { + let workspace_root = + crate::agentic::workspace::session_execution_workspace_root(&session.config); + let external_sources_supported = workspace + .as_ref() + .is_some_and(|workspace| !workspace.is_remote()); + let expected_owner = agent_type + .eq_ignore_ascii_case(&session.agent_type) + .then_some(session.config.agent_route_owner); + Self::resolve_primary_agent_for_workspace( + agent_type, + workspace_root, + external_sources_supported, + expected_owner, + ) + .await + } + fn ensure_user_message_metadata_object( metadata: Option, ) -> serde_json::Value { @@ -2295,9 +2420,27 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet config.remote_ssh_host.as_deref(), )?; config.workspace_id = Self::resolve_workspace_id_for_config(&config).await; + let agent_type = Self::normalize_agent_type(&agent_type); + let workspace_binding = Self::build_workspace_binding(&config).await; + let external_workspace_root = + crate::agentic::workspace::session_execution_workspace_root(&config); + let external_sources_supported = workspace_binding + .as_ref() + .is_some_and(|workspace| !workspace.is_remote()); + let primary_agent_binding = Self::resolve_primary_agent_for_workspace( + &agent_type, + external_workspace_root, + external_sources_supported, + None, + ) + .await?; + config.agent_route_owner = primary_agent_binding.route_owner; + apply_primary_agent_model_default( + &mut config, + primary_agent_binding.model_binding.as_ref(), + ); let defaults = Self::agent_model_defaults().await; snapshot_normal_session_model(&mut config, &defaults); - let agent_type = Self::normalize_agent_type(&agent_type); let session = if transient { self.session_manager .create_transient_session_with_id_and_details( @@ -3324,11 +3467,23 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet })?; } + let effective_agent_type = Self::normalize_agent_type(agent_type.trim()); + let session_workspace = Self::build_workspace_binding(&session.config).await; + let primary_agent_binding = Self::resolve_session_primary_agent( + &session, + &effective_agent_type, + &session_workspace, + ) + .await?; + let primary_runtime_agent_key = primary_agent_binding.runtime_agent_key.clone(); + let primary_route_owner = primary_agent_binding.route_owner; + let primary_agent_generation_lease = primary_agent_binding.lease; + let binding = get_agent_registry() .resolve_external_subagent_for_fresh_invocation( &logical_id, &ecosystem_id, - Some(Path::new(&project_workspace_path)), + Some(Path::new(&execution_workspace_path)), ) .ok_or_else(|| { BitFunError::Validation(format!( @@ -3342,15 +3497,21 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) })?; - let effective_agent_type = Self::normalize_agent_type(agent_type.trim()); let permission_runtime_ceiling = - crate::agentic::permission_policy::load_parent_permission_runtime_ceiling(Some( - &effective_agent_type, - )) + crate::agentic::permission_policy::load_parent_permission_runtime_ceiling( + Some(&primary_runtime_agent_key), + Some(Path::new(&execution_workspace_path)), + ) .await?; - if session.agent_type != effective_agent_type { + if session.agent_type != effective_agent_type + || session.config.agent_route_owner != primary_route_owner + { self.session_manager - .update_session_agent_type(&session_id, &effective_agent_type) + .update_session_agent_binding( + &session_id, + &effective_agent_type, + primary_route_owner, + ) .await?; } let display_input = original_user_input @@ -3486,6 +3647,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet tokio::spawn(async move { let _execution_lease = execution_lease; let _turn_settlement_registration = turn_settlement_registration; + let _primary_agent_generation_lease = primary_agent_generation_lease; let _cancel_guard = CancelTokenGuard { execution_engine: Arc::clone(&coordinator.execution_engine), dialog_turn_id: turn_id.clone(), @@ -4528,6 +4690,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ))); } + let manual_workspace = Self::build_workspace_binding(&initial_session.config).await; + let primary_agent_binding = Self::resolve_session_primary_agent( + &initial_session, + &initial_session.agent_type, + &manual_workspace, + ) + .await?; + let runtime_agent_type = primary_agent_binding.runtime_agent_key; + let external_agent_generation_lease = primary_agent_binding.lease; + self.commit_session_revert_before_persisted_turn_locked(&session_id, "Manual compaction") .await?; let user_message_metadata = Some(Self::manual_compaction_metadata()); @@ -4591,6 +4763,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet tokio::spawn(async move { let _execution_lease = execution_lease; + let _external_agent_generation_lease = external_agent_generation_lease; let _settlement = settlement; let _control_guard = control_guard; let result = Self::execute_manual_compaction_task( @@ -4602,6 +4775,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id_for_task, turn_id_for_task, turn_index, + runtime_agent_type, + manual_workspace, terminal_port, remote_exec_port, cancellation_token, @@ -4708,18 +4883,19 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: String, turn_id: String, turn_index: usize, + runtime_agent_type: String, + manual_workspace: Option, terminal_port: Option>, remote_exec_port: Option>, cancellation_token: CancellationToken, commit_gate: Arc, ) -> BitFunResult<()> { - let manual_workspace = Self::build_workspace_binding(&session.config).await; let manual_workspace_services = Self::build_workspace_services(&manual_workspace).await; let manual_execution_context = ExecutionContext { session_id: session_id.clone(), dialog_turn_id: turn_id.clone(), turn_index, - agent_type: session.agent_type.clone(), + agent_type: runtime_agent_type, workspace: manual_workspace, context: HashMap::new(), subagent_parent_info: None, @@ -4936,6 +5112,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } }; self.ensure_session_runtime_ownership(&session_id, None)?; + let session_workspace = Self::build_workspace_binding(&session.config).await; let previous_agent_type = session.last_user_dialog_agent_type.clone(); let requested_agent_type = agent_type.trim().to_string(); @@ -4947,6 +5124,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "agentic".to_string() }; let effective_agent_type = Self::normalize_agent_type(&provisional_agent_type); + let primary_agent_binding = Self::resolve_session_primary_agent( + &session, + &effective_agent_type, + &session_workspace, + ) + .await?; + let runtime_agent_type = primary_agent_binding.runtime_agent_key.clone(); + let external_agent_generation_lease = primary_agent_binding.lease; Self::track_session_workspace_activity_best_effort( &session.config, @@ -4974,9 +5159,15 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet submission_policy.queue_priority ); - if session.agent_type != effective_agent_type { + if session.agent_type != effective_agent_type + || session.config.agent_route_owner != primary_agent_binding.route_owner + { self.session_manager - .update_session_agent_type(&session_id, &effective_agent_type) + .update_session_agent_binding( + &session_id, + &effective_agent_type, + primary_agent_binding.route_owner, + ) .await?; } @@ -5203,8 +5394,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet user_message_metadata = Some(metadata); } - let session_workspace = Self::build_workspace_binding(&session.config).await; - // Build WorkspaceServices based on the workspace type let workspace_services = Self::build_workspace_services(&session_workspace).await; @@ -5268,7 +5457,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .wrap_user_input( &session_id, turn_index, - &effective_agent_type, + &runtime_agent_type, previous_agent_type .as_deref() .map(str::trim) @@ -5605,10 +5794,15 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let user_input_for_workspace = effective_user_input.clone(); let session_storage_path_for_finalize = session_storage_path.clone(); let effective_agent_type_clone = effective_agent_type.clone(); + let runtime_agent_type_clone = runtime_agent_type; let user_message_metadata_clone = user_message_metadata; let scheduler_notify_tx = self.scheduler_notify_tx.get().cloned(); tokio::spawn(async move { + // Keep the exact approved external prompt/tool/permission/model + // generation alive for the whole turn. Source updates affect only + // the next turn. + let _external_agent_generation_lease = external_agent_generation_lease; // Keep exact turn settlement pending until every tail write in // this spawned task has completed. let _turn_settlement_registration = turn_settlement_registration; @@ -5689,11 +5883,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } let workspace_turn_status = match execution_engine - .execute_dialog_turn( - effective_agent_type_clone.clone(), - messages, - execution_context, - ) + .execute_dialog_turn(runtime_agent_type_clone, messages, execution_context) .await { Ok(execution_result) => Some( @@ -10291,18 +10481,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(normalized) } - pub async fn update_session_agent_type( - &self, - session_id: &str, - agent_type: &str, - ) -> BitFunResult<()> { - self.ensure_session_runtime_ownership(session_id, None)?; - let normalized = Self::normalize_agent_type(agent_type); - self.session_manager - .update_session_agent_type(session_id, &normalized) - .await - } - pub async fn update_session_mode(&self, session_id: &str, mode_id: &str) -> BitFunResult<()> { self.ensure_session_runtime_ownership(session_id, None)?; let mode_id = mode_id.trim(); @@ -10312,19 +10490,26 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet )); } - let mode_exists = get_agent_registry() - .get_modes_info() - .await - .into_iter() - .any(|mode| mode.id == mode_id); - if !mode_exists { - return Err(BitFunError::Validation(format!( - "Unknown session mode: {mode_id}" - ))); - } + let session = self + .session_manager + .get_session(session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; + let workspace = Self::build_workspace_binding(&session.config).await; + let workspace_root = + crate::agentic::workspace::session_execution_workspace_root(&session.config); + let external_sources_supported = workspace + .as_ref() + .is_some_and(|workspace| !workspace.is_remote()); + let binding = Self::resolve_primary_agent_for_workspace( + mode_id, + workspace_root, + external_sources_supported, + None, + ) + .await?; self.session_manager - .update_session_agent_type(session_id, mode_id) + .update_session_agent_binding(session_id, mode_id, binding.route_owner) .await } @@ -12401,8 +12586,9 @@ fn merge_prepended_messages_for_turn( #[cfg(test)] mod tests { use super::{ - btw_session_memory_mode, build_subagent_session_relationship, - lineage_active_turn_after_transcript, lineage_post_admission_cancellation_error, + apply_primary_agent_model_default, btw_session_memory_mode, + build_subagent_session_relationship, lineage_active_turn_after_transcript, + lineage_post_admission_cancellation_error, lineage_session_is_settling_without_active_state, logical_subagent_type_or_runtime, merge_prepended_messages_for_turn, normalize_subagent_max_concurrency, resolve_agent_session_create_created_by, resolve_agent_submission_turn_id, @@ -12421,8 +12607,8 @@ mod tests { }; use crate::agentic::core::{ InternalReminderKind, Message, MessageContent, MessageRole, MessageSemanticKind, - ProcessingPhase, SessionConfig, SessionContinuationPolicy, SessionKind, - SessionModelBindingPolicy, SessionState, ToolCall, TurnStats, + ProcessingPhase, SessionAgentRouteOwner, SessionConfig, SessionContinuationPolicy, + SessionKind, SessionModelBindingPolicy, SessionState, ToolCall, TurnStats, }; use crate::agentic::events::{AgenticEvent, EventQueue, EventQueueConfig, EventRouter}; use crate::agentic::execution::{ @@ -12450,6 +12636,61 @@ mod tests { use bitfun_runtime_services::test_support::FakeRuntimePort; use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; + #[test] + fn external_command_delegation_uses_the_resolved_primary_binding() { + let source = include_str!("coordinator.rs").replace("\r\n", "\n"); + let delegation = source + .split_once("pub(crate) fn start_external_subagent_delegation_turn(") + .expect("external command delegation entry") + .1 + .split_once("pub async fn start_dialog_turn_with_prepended_messages(") + .expect("external command delegation boundary") + .0; + + assert!(delegation.contains("Self::resolve_session_primary_agent(")); + assert!(delegation.contains("Some(&primary_runtime_agent_key)")); + assert!(delegation.contains(".update_session_agent_binding(")); + assert!(!delegation.contains(".update_session_agent_type(")); + assert!(delegation + .contains("let _primary_agent_generation_lease = primary_agent_generation_lease;")); + } + + #[test] + fn external_primary_fixed_model_is_only_a_creation_default() { + let fixed = ExternalSubagentModelBinding::Fixed { + model_id: "provider/profile-model".to_string(), + configuration_fingerprint: "fingerprint".to_string(), + }; + + let mut omitted = SessionConfig::default(); + apply_primary_agent_model_default(&mut omitted, Some(&fixed)); + assert_eq!(omitted.model_id.as_deref(), Some("provider/profile-model")); + + let mut automatic = SessionConfig { + model_id: Some("auto".to_string()), + ..SessionConfig::default() + }; + apply_primary_agent_model_default(&mut automatic, Some(&fixed)); + assert_eq!( + automatic.model_id.as_deref(), + Some("provider/profile-model") + ); + + let mut explicit = SessionConfig { + model_id: Some("provider/user-model".to_string()), + ..SessionConfig::default() + }; + apply_primary_agent_model_default(&mut explicit, Some(&fixed)); + assert_eq!(explicit.model_id.as_deref(), Some("provider/user-model")); + + let mut inherited = SessionConfig::default(); + apply_primary_agent_model_default( + &mut inherited, + Some(&ExternalSubagentModelBinding::InheritParent), + ); + assert_eq!(inherited.model_id, None); + } + #[test] fn terminal_persisted_turn_is_not_replayed_as_active() { assert_eq!( @@ -12720,6 +12961,110 @@ mod tests { assert!(!gate.try_cancel()); } + #[tokio::test] + async fn manual_compaction_fails_closed_before_admission_when_external_agent_is_unavailable() { + let (coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let session_id = format!("external-compact-{}", uuid::Uuid::new_v4()); + let external_agent_id = format!("missing-external-{}", uuid::Uuid::new_v4()); + session_manager + .create_session_with_id( + Some(session_id.clone()), + "External compaction".to_string(), + external_agent_id.clone(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create session"); + session_manager + .update_session_agent_binding( + &session_id, + &external_agent_id, + SessionAgentRouteOwner::External, + ) + .await + .expect("persist external route owner"); + + let error = match coordinator + .start_manual_compaction_task(session_id.clone(), None) + .await + { + Ok(_) => panic!("manual compaction must not bypass an unavailable external route"), + Err(error) => error, + }; + + assert!(error.to_string().contains("candidate_unavailable")); + let session = session_manager + .get_session(&session_id) + .expect("session remains loaded"); + assert!(matches!(session.state, SessionState::Idle)); + assert!(session.dialog_turn_ids.is_empty()); + } + + #[tokio::test] + async fn explicit_agent_change_switches_owner_but_case_variant_does_not() { + let (_coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let session_id = format!("external-to-local-{}", uuid::Uuid::new_v4()); + let external_agent_id = format!("external-profile-{}", uuid::Uuid::new_v4()); + session_manager + .create_session_with_id( + Some(session_id.clone()), + "External to local".to_string(), + external_agent_id.clone(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create session"); + session_manager + .update_session_agent_binding( + &session_id, + &external_agent_id, + SessionAgentRouteOwner::External, + ) + .await + .expect("persist external route owner"); + let session = session_manager + .get_session(&session_id) + .expect("session remains loaded"); + let workspace = ConversationCoordinator::build_workspace_binding(&session.config).await; + + let binding = + ConversationCoordinator::resolve_session_primary_agent(&session, "agentic", &workspace) + .await + .expect( + "explicitly selected local mode should resolve independently of the old owner", + ); + + assert_eq!(binding.runtime_agent_key, "agentic"); + assert_eq!(binding.route_owner, SessionAgentRouteOwner::Local); + + session_manager + .update_session_agent_binding(&session_id, "AGENTIC", SessionAgentRouteOwner::External) + .await + .expect("persist case-variant external route owner"); + let case_variant_session = session_manager + .get_session(&session_id) + .expect("case-variant session remains loaded"); + let error = match ConversationCoordinator::resolve_session_primary_agent( + &case_variant_session, + "agentic", + &workspace, + ) + .await + { + Ok(_) => panic!("case variants of the same external identity must remain fail-closed"), + Err(error) => error, + }; + assert!(error.to_string().contains("candidate_unavailable")); + } + #[test] fn manual_compaction_transcript_restores_user_and_tool_payload() { let outcome = ContextCompactionOutcome { diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 1e05b03ca1..bcab312c81 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -641,10 +641,24 @@ impl DialogScheduler { user_message_metadata: Option, ) -> Result<(), String> { let _operation_guard = self.lock_session_operation(&session_id).await; + let session_agent_type = self + .resolve_session_agent_type( + &session_id, + workspace_path.as_deref(), + remote_connection_id.as_deref(), + remote_ssh_host.as_deref(), + ) + .await?; + if session_agent_type != agent_type { + debug!( + "Background result delivery replaced execution agent key with Session logical route: session_id={}, execution_agent_type={}, session_agent_type={}", + session_id, agent_type, session_agent_type + ); + } let display = display_content.unwrap_or_else(|| content.clone()); let delivery = BackgroundResultDelivery { session_id: session_id.clone(), - agent_type, + agent_type: session_agent_type, workspace_path, remote_connection_id, remote_ssh_host, @@ -2820,6 +2834,40 @@ mod tests { assert_eq!(scheduler.queue_depth(session_id), 0); } + #[tokio::test] + async fn idle_background_result_uses_the_session_logical_agent_route() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "external-parent-session"; + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "External parent".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create parent session"); + + scheduler + .deliver_background_result( + session_id.to_string(), + "external::opencode::agentic::generation-v1".to_string(), + None, + None, + None, + "Background Bash command completed".to_string(), + None, + None, + ) + .await + .expect("lifecycle delivery must follow the persisted logical session route"); + } + fn standard_queued_turn(turn_id: &str) -> QueuedTurn { QueuedTurn { user_input: "queued".to_string(), diff --git a/src/crates/assembly/core/src/agentic/core/mod.rs b/src/crates/assembly/core/src/agentic/core/mod.rs index 93187168dc..f3cfdd1f0a 100644 --- a/src/crates/assembly/core/src/agentic/core/mod.rs +++ b/src/crates/assembly/core/src/agentic/core/mod.rs @@ -21,7 +21,7 @@ pub use message::{ pub use messages_helper::{MessageHelper, RequestReasoningTokenPolicy}; pub use session::{ sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, - SessionConfig, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, - SessionSummary, + SessionAgentRouteOwner, SessionConfig, SessionContinuationPolicy, SessionKind, + SessionModelBindingPolicy, SessionSummary, }; pub use state::{ProcessingPhase, SessionState, ToolExecutionState}; diff --git a/src/crates/assembly/core/src/agentic/core/session.rs b/src/crates/assembly/core/src/agentic/core/session.rs index 3eb1f7101b..5d2142c213 100644 --- a/src/crates/assembly/core/src/agentic/core/session.rs +++ b/src/crates/assembly/core/src/agentic/core/session.rs @@ -2,6 +2,6 @@ pub use bitfun_agent_runtime::session::{ sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, - SessionConfig, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, - SessionSummary, + SessionAgentRouteOwner, SessionConfig, SessionContinuationPolicy, SessionKind, + SessionModelBindingPolicy, SessionSummary, }; diff --git a/src/crates/assembly/core/src/agentic/permission_policy.rs b/src/crates/assembly/core/src/agentic/permission_policy.rs index d402099557..69b0e9057e 100644 --- a/src/crates/assembly/core/src/agentic/permission_policy.rs +++ b/src/crates/assembly/core/src/agentic/permission_policy.rs @@ -9,8 +9,9 @@ use bitfun_runtime_ports::{ pub(crate) fn derive_parent_permission_runtime_ceiling( agent_profile: Option<&AgentProfileConfig>, + agent_definition_constraints: Option<&PermissionConstraintLayer>, ) -> PermissionRuntimeCeiling { - let rules = agent_profile + let mut rules: Vec = agent_profile .into_iter() .flat_map(|profile| profile.tool_permission_rules.iter()) .filter(|rule| { @@ -19,6 +20,13 @@ pub(crate) fn derive_parent_permission_runtime_ceiling( }) .cloned() .collect(); + rules.extend( + agent_definition_constraints + .into_iter() + .flat_map(PermissionConstraintLayer::rules) + .filter(|rule| rule.effect != PermissionEffect::Allow) + .cloned(), + ); PermissionRuntimeCeiling::try_new(rules) .expect("parent permission ceiling extraction must exclude allow rules") @@ -26,6 +34,7 @@ pub(crate) fn derive_parent_permission_runtime_ceiling( pub(crate) async fn load_parent_permission_runtime_ceiling( agent_type: Option<&str>, + workspace_root: Option<&std::path::Path>, ) -> BitFunResult { let service = GlobalConfigManager::get_service().await?; let global: GlobalConfig = service.get_config(None).await?; @@ -33,7 +42,15 @@ pub(crate) async fn load_parent_permission_runtime_ceiling( let profile_id = crate::agentic::agents::resolve_mode_config_profile_id(agent_type); global.ai.agent_profiles.get(profile_id.as_ref()) }); - Ok(derive_parent_permission_runtime_ceiling(profile)) + let definition_constraints = agent_type.and_then(|agent_type| { + crate::agentic::agents::get_agent_registry() + .get_agent(agent_type, workspace_root) + .map(|agent| agent.permission_constraints().clone()) + }); + Ok(derive_parent_permission_runtime_ceiling( + profile, + definition_constraints.as_ref(), + )) } pub(crate) fn resolve_effective_permission_policy( @@ -87,7 +104,7 @@ mod tests { } #[test] - fn parent_ceiling_keeps_only_profile_denies_and_external_directory_asks() { + fn parent_ceiling_combines_profile_and_agent_constraints_without_allows() { let profile = AgentProfileConfig { tool_permission_rules: vec![ rule("read", "*", PermissionEffect::Allow), @@ -100,7 +117,13 @@ mod tests { ..AgentProfileConfig::default() }; - let ceiling = derive_parent_permission_runtime_ceiling(Some(&profile)); + let definition_constraints = PermissionConstraintLayer::new(vec![ + rule("bash", "git push *", PermissionEffect::Ask), + rule("bash", "git status", PermissionEffect::Allow), + rule("edit", "secrets/*", PermissionEffect::Deny), + ]); + let ceiling = + derive_parent_permission_runtime_ceiling(Some(&profile), Some(&definition_constraints)); assert_eq!( ceiling.rules(), @@ -108,6 +131,8 @@ mod tests { rule("bash", "rm *", PermissionEffect::Deny), rule("external_directory", "*", PermissionEffect::Ask), rule("external_directory", "C:/blocked", PermissionEffect::Deny), + rule("bash", "git push *", PermissionEffect::Ask), + rule("edit", "secrets/*", PermissionEffect::Deny), ] ); assert!(ceiling 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 8a2f3f30b9..5eb7dcba00 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -5,8 +5,9 @@ use crate::agentic::agents::get_agent_registry; use crate::agentic::core::{ new_turn_id, CompressionContract, CompressionState, InternalReminderKind, Message, - MessageContent, MessageRole, MessageSemanticKind, ProcessingPhase, Session, SessionConfig, - SessionKind, SessionModelBindingPolicy, SessionState, SessionSummary, TurnStats, + MessageContent, MessageRole, MessageSemanticKind, ProcessingPhase, Session, + SessionAgentRouteOwner, SessionConfig, SessionKind, SessionModelBindingPolicy, SessionState, + SessionSummary, TurnStats, }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::keyed_lock::{KeyedAsyncLock, KeyedAsyncLockGuard}; @@ -3388,8 +3389,11 @@ impl SessionManager { Ok(true) } - /// Update session agent type (in-memory + persistence) - pub async fn update_session_agent_type( + /// Legacy mutation helper retained only for persistence-focused unit tests. + /// Production callers must update the logical id and route owner atomically + /// through `update_session_agent_binding`. + #[cfg(test)] + async fn update_session_agent_type( &self, session_id: &str, agent_type: &str, @@ -3441,6 +3445,75 @@ impl SessionManager { Ok(()) } + /// Update the logical main-agent id and its durable route owner together. + /// + /// The owner is part of the execution binding: an externally owned Session + /// must remain fail-closed after restart instead of resolving a same-name + /// local mode. Persist the complete Session so metadata and state sidecar + /// cannot disagree about this pair. + pub async fn update_session_agent_binding( + &self, + session_id: &str, + agent_type: &str, + route_owner: SessionAgentRouteOwner, + ) -> BitFunResult<()> { + let _mutation_guard = self.acquire_session_mutation(session_id).await?; + let original_session = self + .sessions + .get(session_id) + .map(|session| session.clone()) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; + if original_session.agent_type == agent_type + && original_session.config.agent_route_owner == route_owner + { + return Ok(()); + } + + let mut updated_session = original_session.clone(); + let now = SystemTime::now(); + updated_session.agent_type = agent_type.to_string(); + updated_session.config.agent_route_owner = route_owner; + updated_session.updated_at = now; + updated_session.last_activity_at = now; + + if self.should_persist_session_id(session_id) { + if let Some(workspace_path) = self.effective_session_storage_path(session_id).await { + if let Err(error) = self + .persistence_manager + .save_session(&workspace_path, &updated_session) + .await + { + if let Err(rollback_error) = self + .persistence_manager + .save_session(&workspace_path, &original_session) + .await + { + return Err(BitFunError::session(format!( + "Session agent binding persistence failed and rollback did not complete: session_id={session_id}, error={error}, rollback_error={rollback_error}" + ))); + } + return Err(error); + } + } + } + + let Some(mut active_session) = self.sessions.get_mut(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {session_id}" + ))); + }; + active_session.agent_type = updated_session.agent_type; + active_session.config.agent_route_owner = route_owner; + active_session.updated_at = now; + active_session.last_activity_at = now; + debug!( + "Session agent binding updated: session_id={}, agent_type={}, route_owner={:?}", + session_id, agent_type, route_owner + ); + + Ok(()) + } + /// Update the most recent scheduler-accepted user submission mode. /// /// This state is intentionally independent from rollback-sensitive history @@ -5001,11 +5074,58 @@ impl SessionManager { let mut auto_migrated_model_id = None; if !include_internal { - let available_modes = get_agent_registry().get_modes_info().await; - if !available_modes - .iter() - .any(|mode| mode.id == session.agent_type) - { + let external_workspace_root = + crate::agentic::workspace::session_execution_workspace_root(&session.config); + let workspace_path_is_remote = match external_workspace_root { + Some(path) => { + crate::service::remote_ssh::workspace_state::is_remote_path( + &path.to_string_lossy(), + ) + .await + } + None => false, + }; + let external_sources_supported = session.config.remote_connection_id.is_none() + && session.config.remote_ssh_host.is_none() + && !workspace_path_is_remote; + if external_sources_supported { + if let Err(error) = + crate::external_sources::ensure_external_source_workspace_snapshot( + external_workspace_root, + ) + .await + { + warn!( + "External agent source discovery failed during session restore: session_id={}, error_category={}", + session.session_id, + crate::external_sources::external_integration_error_code(&error), + ); + } + } + let agent_registry = get_agent_registry(); + agent_registry + .load_custom_agents(external_workspace_root) + .await; + let available_modes = agent_registry + .get_modes_info_for_workspace(external_workspace_root, external_sources_supported) + .await; + let persisted_binding = agent_registry.resolve_primary_agent_for_turn( + &session.agent_type, + external_workspace_root, + external_sources_supported, + Some(session.config.agent_route_owner), + ); + if let Some(binding) = persisted_binding { + if session.config.agent_route_owner != binding.route_owner { + session.config.agent_route_owner = binding.route_owner; + should_persist_restored_session = true; + } + } else if session.config.agent_route_owner == SessionAgentRouteOwner::External { + warn!( + "Persisted external main agent is currently unavailable; preserving fail-closed session binding: session_id={}, persisted_mode={}", + session.session_id, session.agent_type + ); + } else { let fallback_mode = available_modes .iter() .find(|mode| mode.id == "agentic") @@ -5022,6 +5142,7 @@ impl SessionManager { session.session_id, session.agent_type, fallback_mode ); session.agent_type = fallback_mode; + session.config.agent_route_owner = SessionAgentRouteOwner::Local; should_persist_restored_session = true; } } @@ -7587,7 +7708,8 @@ mod tests { }; use crate::agentic::core::{ CompressionState, Message, MessageContent, MessageRole, ProcessingPhase, Session, - SessionConfig, SessionModelBindingPolicy, SessionState, ToolCall, ToolResult, + SessionAgentRouteOwner, SessionConfig, SessionModelBindingPolicy, SessionState, ToolCall, + ToolResult, }; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::{ @@ -9480,6 +9602,90 @@ mod tests { assert_eq!(restored.agent_type, "Plan"); } + #[tokio::test] + async fn external_agent_binding_persists_atomically_and_never_restores_as_local_by_name() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Durable external route".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + manager + .update_session_agent_binding( + &session.session_id, + "agentic", + SessionAgentRouteOwner::External, + ) + .await + .expect("same-id local-to-external rebind should persist"); + assert_eq!( + manager + .get_session(&session.session_id) + .expect("rebound session") + .config + .agent_route_owner, + SessionAgentRouteOwner::External + ); + manager + .update_session_agent_binding( + &session.session_id, + "agentic", + SessionAgentRouteOwner::Local, + ) + .await + .expect("same-id external-to-local rebind should persist"); + assert_eq!( + manager + .get_session(&session.session_id) + .expect("rebound session") + .config + .agent_route_owner, + SessionAgentRouteOwner::Local + ); + + manager + .update_session_agent_binding( + &session.session_id, + "Plan", + SessionAgentRouteOwner::External, + ) + .await + .expect("external route update should persist without a turn"); + + let (persisted, _) = persistence_manager + .load_session_with_turns(workspace.path(), &session.session_id) + .await + .expect("persisted session should load"); + assert_eq!(persisted.agent_type, "Plan"); + assert_eq!( + persisted.config.agent_route_owner, + SessionAgentRouteOwner::External + ); + + manager.evict_loaded_session_for_test(&session.session_id); + let restored = manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("external route should restore fail-closed"); + assert_eq!(restored.agent_type, "Plan"); + assert_eq!( + restored.config.agent_route_owner, + SessionAgentRouteOwner::External, + "a same-name local mode must not capture a persisted external route" + ); + } + #[tokio::test] async fn session_mode_update_does_not_rewrite_the_runtime_state_file() { let workspace = TestWorkspace::new(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index 58b8f1c12f..0a244c6514 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -99,6 +99,7 @@ impl TaskTool { ) -> BitFunResult { crate::agentic::permission_policy::load_parent_permission_runtime_ceiling( context.agent_type.as_deref(), + context.workspace_root(), ) .await } diff --git a/src/crates/assembly/core/src/agentic/workspace.rs b/src/crates/assembly/core/src/agentic/workspace.rs index 7136addedd..da16fddb7f 100644 --- a/src/crates/assembly/core/src/agentic/workspace.rs +++ b/src/crates/assembly/core/src/agentic/workspace.rs @@ -1,3 +1,4 @@ +use crate::agentic::core::SessionConfig; use crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity; use crate::service::workspace_runtime::WorkspaceRuntimeService; use bitfun_core_types::SessionExecutionTarget; @@ -13,18 +14,41 @@ pub use bitfun_services_integrations::remote_ssh::{ }; use std::path::{Path, PathBuf}; +/// Return the stable identity path used by local workspace-scoped registries. +/// +/// Callers may arrive through lexical aliases (`.` / `..`) or filesystem +/// aliases. Keep normalization at the shared workspace boundary so producers +/// and consumers cannot publish and query the same workspace under different +/// keys. A missing path is retained for creation-safe discovery. +pub(crate) fn canonical_local_workspace_path(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + /// Stable local workspace identity shared by per-workspace runtime routers. pub(crate) fn workspace_route_key(workspace_root: Option<&Path>) -> String { workspace_root .map(|path| { - dunce::canonicalize(path) - .unwrap_or_else(|_| path.to_path_buf()) + canonical_local_workspace_path(path) .to_string_lossy() .into_owned() }) .unwrap_or_else(|| "".to_string()) } +/// Return the Session root that owns execution-scoped discovery and routing. +/// +/// A managed worktree executes from `workspace_path` while Session persistence +/// remains anchored at `project_workspace_path`. Extension sources and agent +/// routes must follow the former so UI discovery and turn execution share one +/// workspace identity. +pub(crate) fn session_execution_workspace_root(config: &SessionConfig) -> Option<&Path> { + config + .workspace_path + .as_deref() + .or(config.project_workspace_path.as_deref()) + .map(Path::new) +} + /// Describes whether the workspace is local or remote via SSH. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum WorkspaceBackend { @@ -183,7 +207,8 @@ impl WorkspaceBinding { #[cfg(test)] mod tests { - use super::{WorkspaceBackend, WorkspaceBinding}; + use super::{session_execution_workspace_root, WorkspaceBackend, WorkspaceBinding}; + use crate::agentic::core::SessionConfig; use crate::service::remote_ssh::workspace_state::{ remote_workspace_session_mirror_dir, workspace_session_identity, }; @@ -193,6 +218,29 @@ mod tests { }; use std::path::PathBuf; + #[test] + fn session_execution_root_prefers_worktree_over_persistence_root() { + let config = SessionConfig { + workspace_path: Some("D:/worktrees/feature".to_string()), + project_workspace_path: Some("D:/projects/main".to_string()), + ..SessionConfig::default() + }; + + assert_eq!( + session_execution_workspace_root(&config), + Some(std::path::Path::new("D:/worktrees/feature")) + ); + + let legacy = SessionConfig { + project_workspace_path: Some("D:/projects/main".to_string()), + ..SessionConfig::default() + }; + assert_eq!( + session_execution_workspace_root(&legacy), + Some(std::path::Path::new("D:/projects/main")) + ); + } + #[test] fn remote_workspace_binding_uses_session_identity_storage_dir() { let session_identity = workspace_session_identity( diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 74d5ba6f43..97b20519e0 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -4000,7 +4000,7 @@ pub(crate) fn normalize_workspace_root( return Err("external source workspace root must be absolute".to_string()); } Ok(Some( - dunce::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf()), + crate::agentic::workspace::canonical_local_workspace_path(workspace_root), )) } @@ -6791,37 +6791,48 @@ fn incompatible_policy_error(detail: impl Into) -> String { ) } -fn external_integration_error_code(error: &str) -> String { +pub(crate) fn external_integration_error_code(error: &str) -> String { ExternalSourceOperationError::decode(error) .map(|error| error.code.as_str().to_string()) .unwrap_or_else(|| "internal".to_string()) } +/// Ensure the workspace-scoped static source snapshot has been published once. +/// This is the shared discovery gate for selectors and execution routing; it +/// does not start or recover any executable extension runtime. +pub async fn ensure_external_source_workspace_snapshot( + workspace_root: Option<&Path>, +) -> Result<(), String> { + ensure_initial_external_source_workspace_service(workspace_root) + .await + .map(|_| ()) +} + +async fn ensure_initial_external_source_workspace_service( + workspace_root: Option<&Path>, +) -> Result, String> { + let service = service_for(workspace_root).await?; + service.ensure_initial_refresh().await?; + Ok(service) +} + /// Keep the external-source runtime aligned with an actively assembled product /// tool catalog. A newly created service performs one synchronous refresh so an /// idle-retired workspace can restore approved routes before the catalog is /// exposed to the model. Existing services are only touched; file watchers and /// explicit refreshes remain responsible for later source changes. pub(crate) async fn ensure_external_source_workspace_runtime(workspace_root: Option<&Path>) { - let service = match service_for(workspace_root).await { + let service = match ensure_initial_external_source_workspace_service(workspace_root).await { Ok(service) => service, Err(error) => { log::warn!( - "Could not retain external source workspace runtime scope={} error_category={}", + "Could not initialize external source workspace runtime scope={} error_category={}", external_log_scope(workspace_root), external_log_error_category(&error), ); return; } }; - if let Err(error) = service.ensure_initial_refresh().await { - log::warn!( - "Could not initialize external source workspace runtime scope={} error_category={}", - external_log_scope(workspace_root), - external_log_error_category(&error), - ); - return; - } if external_tool_workspace_requires_recovery(workspace_root).await { if let Err(error) = service.refresh_worker_loss_once().await { log::warn!( diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index 5a0c882bf4..0b4bf06cfb 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -7,7 +7,7 @@ use crate::agentic::agents::{ external_subagent_runtime_key, get_agent_registry, AgentInfo, AgentSource, - ExternalProvidedSubagent, ExternalSubagentModelBinding, ExternalSubagentRegistration, + ExternalProvidedAgent, ExternalSubagentModelBinding, ExternalSubagentRegistration, ExternalSubagentRoute, }; use crate::agentic::tools::registry::get_all_registered_tools; @@ -16,7 +16,6 @@ use crate::external_sources::safe_external_source_location; use crate::external_tools::resolve_external_tool_for_workspace; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{model_runtime_binding_fingerprint, AIConfig, AIModelConfig}; -use crate::service::config::SubagentModelSelection; use crate::util::BitFunError; use bitfun_external_sources::ExternalSubagentCoordinatorSnapshot; use bitfun_product_domains::external_sources::EcosystemId; @@ -283,7 +282,7 @@ async fn gather_product_facts( let registry = get_agent_registry(); let mut locals = BTreeMap::new(); for info in registry - .get_local_subagents_for_external_resolution(workspace_root) + .get_local_agents_for_external_resolution(workspace_root) .await { let logical_key = normalize_logical_id(&info.id); @@ -293,8 +292,12 @@ async fn gather_product_facts( // offering a candidate that the Local route could not execute. continue; } - let model = match ai_config.as_ref() { - Some(ai_config) => { + let model = match (info.subagent_source, ai_config.as_ref()) { + // Main-agent profiles do not own the mutable session model. Their + // conflict identity is the profile itself; the user's current + // model selection must not invalidate an external-source choice. + (None, _) => "main-agent-profile".to_string(), + (Some(_), Some(ai_config)) => { let model_selection = registry .get_explicit_subagent_model_selection(&info.id, workspace_root) .unwrap_or_else(|| { @@ -305,7 +308,7 @@ async fn gather_product_facts( serde_json::to_string(&model_selection) .unwrap_or_else(|_| "unavailable".to_string()) } - None => "configuration-unavailable".to_string(), + (Some(_), None) => "configuration-unavailable".to_string(), }; locals.insert(logical_key, local_candidate_fact(&info, &model)); } @@ -570,30 +573,6 @@ fn external_model_binding_options(ai_config: &AIConfig) -> Vec Option { - match ai_config - .agent_model_defaults - .builtin_subagent_selection(logical_id) - { - SubagentModelSelection::Inherit => None, - SubagentModelSelection::Fixed { model_id } => { - let requested = model_id.trim(); - if requested.is_empty() { - return None; - } - let runtime_id = ai_config.resolve_model_selection(requested)?; - let model = ai_config - .models - .iter() - .find(|model| model.enabled && model.id == runtime_id)?; - Some(resolved_model_fact(model)) - } - } -} - fn resolve_model_request( definition: &ExternalSubagentDefinition, ecosystem_id: Option<&EcosystemId>, @@ -604,11 +583,16 @@ fn resolve_model_request( model_bindings: &BTreeMap, ) -> ResolvedModelRequest { let (automatic_model, automatic_method) = match &definition.requested_model { + // An omitted external model means "use the caller's current model" in + // both OpenCode and Claude Code. It must not be guessed from a + // same-name BitFun subagent default: that couples an external profile + // to an unrelated local definition and changes behavior on collisions. ExternalSubagentModelRequest::Default => ( - ai_config - .and_then(|config| resolve_bitfun_subagent_model(&definition.logical_id, config)) - .map(ResolvedCandidateModel::Fixed) - .unwrap_or(ResolvedCandidateModel::Unavailable), + if ai_config.is_some() { + ResolvedCandidateModel::InheritParent + } else { + ResolvedCandidateModel::Unavailable + }, ExternalSubagentModelBindingMethod::Default, ), ExternalSubagentModelRequest::Inherit => ( @@ -1280,10 +1264,10 @@ fn install_active_candidate( // behavior approval. Keep that projection host-owned and stable while the // review surface continues to show the source description. let runtime_description = format!( - "Approved external subagent from {}. Runs as a fresh single-run task.", + "Approved external agent profile from {}.", candidate.provider_label ); - let agent = Arc::new(ExternalProvidedSubagent::new( + let agent = Arc::new(ExternalProvidedAgent::new( runtime_key.clone(), candidate.definition.display_name.clone(), runtime_description, @@ -1308,6 +1292,7 @@ fn install_active_candidate( provider_label: candidate.provider_label.clone(), model_binding, hidden: candidate.definition.hidden, + mode: candidate.definition.mode, agent, }); state.routes.insert( @@ -1330,6 +1315,7 @@ fn summary_for( source_keys: candidate.source_keys.clone(), source_location_labels: candidate.source_location_labels.clone(), source_count: candidate.definition.provenance.len(), + mode: candidate.definition.mode, requested_model: candidate.definition.requested_model.clone(), requested_model_profile: candidate.definition.requested_model_profile.clone(), model_binding_method: candidate.model_binding_method, @@ -1439,6 +1425,7 @@ fn stable_digest(parts: impl IntoIterator>) -> String { #[cfg(test)] mod tests { use super::*; + use crate::service::config::SubagentModelSelection; fn test_active_ecosystems() -> &'static BTreeSet { static ECOSYSTEMS: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -1806,35 +1793,42 @@ mod tests { } #[test] - fn default_external_model_materializes_an_enabled_authoritative_selection() { - let mut config = AIConfig { - models: vec![active_model( - "model_review", - "Anthropic", - "anthropic", - "claude-sonnet-4", - )], - ..AIConfig::default() - }; - config.agent_model_defaults.subagents.default_selection = - SubagentModelSelection::fixed("model_review"); - - let resolved = resolve_bitfun_subagent_model("reviewer", &config) - .expect("configured subagent default should resolve"); - assert_eq!(resolved.runtime_id, "model_review"); - assert_eq!(resolved.display_label, "Anthropic · claude-sonnet-4"); - - config.agent_model_defaults.subagents.default_selection = SubagentModelSelection::Inherit; - assert!(resolve_bitfun_subagent_model("reviewer", &config).is_none()); + fn omitted_external_model_inherits_without_consulting_local_subagent_defaults() { + let mut product_facts = facts(); + product_facts + .ai_config + .as_mut() + .expect("test AI config") + .agent_model_defaults + .subagents + .default_selection = SubagentModelSelection::fixed("model_fast"); + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); - config.agent_model_defaults.subagents.default_selection = - SubagentModelSelection::fixed("fast"); - assert!(resolve_bitfun_subagent_model("reviewer", &config).is_none()); + let state = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + model_bindings: empty_model_bindings(), + }, + &product_facts, + ); - config.agent_model_defaults.subagents.default_selection = - SubagentModelSelection::fixed("model_review"); - config.models[0].enabled = false; - assert!(resolve_bitfun_subagent_model("reviewer", &config).is_none()); + assert_eq!( + state.summaries[0].model_binding_method, + ExternalSubagentModelBindingMethod::Default + ); + assert_eq!(state.summaries[0].effective_model_label, None); + assert_eq!( + state.summaries[0].activation_state, + ExternalSubagentActivationState::ApprovalRequired + ); } #[test] @@ -2420,7 +2414,7 @@ mod tests { } #[test] - fn default_model_change_requires_a_new_approval_for_future_invocations() { + fn inherited_session_model_changes_do_not_invalidate_source_approval() { let empty_set = BTreeSet::new(); let empty_map = BTreeMap::new(); let first_facts = facts(); @@ -2464,19 +2458,23 @@ mod tests { &updated_facts, ); - assert_ne!( + assert_eq!( first.summaries[0].decision_key, updated.summaries[0].decision_key ); assert_eq!( updated.summaries[0].activation_state, - ExternalSubagentActivationState::ApprovalRequired + ExternalSubagentActivationState::Active ); - assert!(updated.registrations.is_empty()); + assert_eq!(updated.registrations.len(), 1); + assert!(matches!( + updated.registrations[0].model_binding, + ExternalSubagentModelBinding::InheritParent + )); } #[test] - fn same_model_id_runtime_identity_change_requires_a_new_approval() { + fn inherited_model_runtime_identity_changes_do_not_invalidate_source_approval() { let empty_set = BTreeSet::new(); let empty_map = BTreeMap::new(); let first_facts = facts(); @@ -2516,19 +2514,23 @@ mod tests { &updated_facts, ); - assert_ne!( + assert_eq!( first.summaries[0].decision_key, updated.summaries[0].decision_key ); assert_eq!( updated.summaries[0].activation_state, - ExternalSubagentActivationState::ApprovalRequired + ExternalSubagentActivationState::Active ); - assert!(updated.registrations.is_empty()); + assert_eq!(updated.registrations.len(), 1); + assert!(matches!( + updated.registrations[0].model_binding, + ExternalSubagentModelBinding::InheritParent + )); } #[test] - fn unresolved_default_model_is_blocked_without_exposing_an_internal_placeholder_label() { + fn omitted_external_model_remains_inheritable_when_local_subagents_inherit() { let empty_set = BTreeSet::new(); let empty_map = BTreeMap::new(); let mut unavailable_facts = facts(); @@ -2557,10 +2559,10 @@ mod tests { assert_eq!( state.summaries[0].activation_state, - ExternalSubagentActivationState::Blocked + ExternalSubagentActivationState::ApprovalRequired ); assert_eq!(state.summaries[0].effective_model_label, None); - assert!(state.registrations.is_empty()); + assert!(state.registrations.is_empty(), "approval is still required"); } #[test] diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index cb235f45c1..ec801de799 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -20,7 +20,8 @@ pub use ai::{ }; pub use errors::{AiErrorDetail, ErrorCategory}; pub use session::{ - validate_session_id, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, + validate_session_id, SessionAgentRouteOwner, SessionContinuationPolicy, SessionKind, + SessionModelBindingPolicy, }; pub use session_usage::*; pub use speech::*; @@ -31,6 +32,6 @@ pub use surface::{ pub use tool_image_attachment::ToolImageAttachment; pub use worktree::{ SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, - WorktreeError, WorktreeErrorCode, WorktreeLifecycle, - WorktreeSessionSummary, WorktreeSettings, WorktreeSummary, + WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSessionSummary, WorktreeSettings, + WorktreeSummary, }; diff --git a/src/crates/contracts/core-types/src/session.rs b/src/crates/contracts/core-types/src/session.rs index 285b5c3997..54fcc354aa 100644 --- a/src/crates/contracts/core-types/src/session.rs +++ b/src/crates/contracts/core-types/src/session.rs @@ -27,6 +27,19 @@ pub enum SessionModelBindingPolicy { ApprovedImmutable, } +/// Durable owner of the session's logical main-agent route. +/// +/// External routes are resolved by Product Assembly for each turn. Persisting +/// only this provider-neutral owner prevents a missing external definition from +/// silently falling back to a same-name local mode after restart. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SessionAgentRouteOwner { + #[default] + Local, + External, +} + pub fn validate_session_id(session_id: &str) -> Result<(), String> { if session_id.is_empty() { return Err("session_id cannot be empty".to_string()); diff --git a/src/crates/contracts/product-domains/src/external_subagents.rs b/src/crates/contracts/product-domains/src/external_subagents.rs index 49d22c6f46..3560cca140 100644 --- a/src/crates/contracts/product-domains/src/external_subagents.rs +++ b/src/crates/contracts/product-domains/src/external_subagents.rs @@ -200,14 +200,25 @@ impl ExternalSubagentProviderIdentity { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ExternalSubagentMode { + #[default] Subagent, All, Primary, } +impl ExternalSubagentMode { + pub fn supports_subagent(self) -> bool { + matches!(self, Self::Subagent | Self::All) + } + + pub fn supports_primary(self) -> bool { + matches!(self, Self::Primary | Self::All) + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde( tag = "kind", @@ -733,6 +744,8 @@ pub struct ExternalSubagentSummary { pub source_keys: Vec, pub source_location_labels: Vec, pub source_count: usize, + #[serde(default)] + pub mode: ExternalSubagentMode, #[serde(default, skip_serializing_if = "is_default_model_request")] pub requested_model: ExternalSubagentModelRequest, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 83d82a21bd..2f59dd1309 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -1102,6 +1102,8 @@ pub struct AgentSessionCreateResult { pub session_name: String, pub agent_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, @@ -1121,6 +1123,7 @@ impl AgentSessionCreateResult { session_id: session_id.into(), session_name: session_name.into(), agent_type: agent_type.into(), + model_id: None, workspace_path: None, workspace_id: None, project_workspace_path: None, @@ -3204,6 +3207,7 @@ mod tests { serde_json::from_value(legacy.clone()).expect("deserialize legacy create result"); assert_eq!(result.workspace_path, None); + assert_eq!(result.model_id, None); assert_eq!(result.workspace_id, None); assert_eq!(result.project_workspace_path, None); assert_eq!(result.execution_target, None); @@ -3219,6 +3223,7 @@ mod tests { "sessionId": "session_1", "sessionName": "Main", "agentType": "agentic", + "modelId": "provider/model", "workspacePath": "/worktrees/session_1", "workspaceId": "workspace_1", "projectWorkspacePath": "/workspace/project", @@ -3238,6 +3243,7 @@ mod tests { result.workspace_path.as_deref(), Some("/worktrees/session_1") ); + assert_eq!(result.model_id.as_deref(), Some("provider/model")); assert_eq!(result.workspace_id.as_deref(), Some("workspace_1")); assert_eq!( result.project_workspace_path.as_deref(), diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index ce087b670d..1de1f21853 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -1,7 +1,8 @@ use crate::session_state::SessionState; pub use bitfun_core_types::SessionKind; pub use bitfun_core_types::{ - SessionContinuationPolicy, SessionExecutionTarget, SessionModelBindingPolicy, + SessionAgentRouteOwner, SessionContinuationPolicy, SessionExecutionTarget, + SessionModelBindingPolicy, }; use serde::{Deserialize, Serialize}; use std::time::SystemTime; @@ -138,6 +139,7 @@ impl Session { impl From for bitfun_runtime_ports::AgentSessionCreateResult { fn from(session: Session) -> Self { let mut result = Self::new(session.session_id, session.session_name, session.agent_type); + result.model_id = session.config.model_id; result.workspace_path = session.config.workspace_path; result.workspace_id = session.config.workspace_id; result.project_workspace_path = session.config.project_workspace_path; @@ -193,6 +195,10 @@ pub struct SessionConfig { /// Mutable sessions leave this unset and continue to resolve selectors. #[serde(default, skip_serializing_if = "Option::is_none")] pub model_binding_fingerprint: Option, + /// Durable owner of the logical main-agent route. External ownership is + /// revalidated for every turn and never falls back by name alone. + #[serde(default, skip_serializing_if = "is_local_agent_route_owner")] + pub agent_route_owner: SessionAgentRouteOwner, } fn is_reusable_continuation_policy(policy: &SessionContinuationPolicy) -> bool { @@ -203,6 +209,10 @@ fn is_mutable_model_binding_policy(policy: &SessionModelBindingPolicy) -> bool { *policy == SessionModelBindingPolicy::Mutable } +fn is_local_agent_route_owner(owner: &SessionAgentRouteOwner) -> bool { + *owner == SessionAgentRouteOwner::Local +} + impl Default for SessionConfig { fn default() -> Self { Self { @@ -222,6 +232,7 @@ impl Default for SessionConfig { continuation_policy: SessionContinuationPolicy::default(), model_binding_policy: SessionModelBindingPolicy::default(), model_binding_fingerprint: None, + agent_route_owner: SessionAgentRouteOwner::Local, } } } @@ -290,7 +301,8 @@ pub fn sanitize_persisted_session_state(state: &SessionState) -> SessionState { mod tests { use super::{ sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, - SessionConfig, SessionContinuationPolicy, SessionModelBindingPolicy, + SessionAgentRouteOwner, SessionConfig, SessionContinuationPolicy, + SessionModelBindingPolicy, }; use crate::session_state::{ProcessingPhase, SessionState}; use bitfun_core_types::{ @@ -322,6 +334,25 @@ mod tests { config.model_binding_policy, SessionModelBindingPolicy::Mutable ); + assert_eq!(config.agent_route_owner, SessionAgentRouteOwner::Local); + } + + #[test] + fn external_agent_route_owner_persists_and_legacy_sessions_default_local() { + let config = SessionConfig { + agent_route_owner: SessionAgentRouteOwner::External, + ..SessionConfig::default() + }; + let mut serialized = serde_json::to_value(&config).expect("serialize session config"); + assert_eq!(serialized["agent_route_owner"], "external"); + + serialized + .as_object_mut() + .expect("session config object") + .remove("agent_route_owner"); + let restored: SessionConfig = + serde_json::from_value(serialized).expect("deserialize legacy session config"); + assert_eq!(restored.agent_route_owner, SessionAgentRouteOwner::Local); } #[test] @@ -391,6 +422,7 @@ mod tests { "Main".to_string(), "agentic".to_string(), SessionConfig { + model_id: Some("provider/model".to_string()), workspace_path: Some("/worktrees/session_1".to_string()), workspace_id: Some("workspace_1".to_string()), project_workspace_path: Some("/workspace/project".to_string()), @@ -404,6 +436,7 @@ mod tests { assert_eq!(result.session_id, "session_1"); assert_eq!(result.session_name, "Main"); assert_eq!(result.agent_type, "agentic"); + assert_eq!(result.model_id.as_deref(), Some("provider/model")); assert_eq!( result.workspace_path.as_deref(), Some("/worktrees/session_1") diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 76aacbd17f..7ce5acc301 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -79,6 +79,8 @@ import { subscribeHistorySessionOpenTransition, } from '../services/sessionOpenIntent'; import { useThreadGoalController } from '../hooks/useThreadGoalController'; +import { useWorkspaceModeCatalog } from '../hooks/useWorkspaceModeCatalog'; +import { useSessionModeSelection } from '../hooks/useSessionModeSelection'; import { useComposerDefaultFocus } from '../hooks/useComposerDefaultFocus'; import { ThreadGoalDialogs } from './thread-goal/ThreadGoalDialogs'; import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; @@ -97,7 +99,7 @@ import { isSessionWorktreeBindingLocked, sessionWorktreeBindingSubscriptionKey, } from '../utils/sessionWorktree'; -import { isRemoteWorkspaceSession } from '../utils/sessionWorkspace'; +import { isRemoteWorkspaceSession, sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; import { isTauriRuntime } from '@/infrastructure/runtime'; import { Tooltip, IconButton, confirmDanger, confirmWarning } from '@/component-library'; import { PendingQueuePanel } from './PendingQueuePanel'; @@ -990,17 +992,12 @@ export const ChatInput: React.FC = ({ switchableModesRef.current = switchableModes; const currentModeRef = useRef(currentMode); currentModeRef.current = currentMode; - const applyModeChangeRef = useRef<((modeId: string) => void) | null>(null); + const publishModeSelectionRef = useRef<((modeId: string) => void) | null>(null); + const requestModeChangeRef = useRef<((modeId: string) => void) | null>(null); const suppressNextUserDefaultModeApplicationRef = useRef(false); - /** Code session: modes switchable on top of default agentic */ - const incrementalCodeModes = useMemo( - () => - switchableModes.filter( - m => m.id !== 'agentic' - ), - [switchableModes] - ); + /** Main-agent modes that can be selected explicitly for this Session. */ + const selectableCodeModes = switchableModes; const openScene = useSceneStore(s => s.openScene); const openCreateAgent = useAgentsStore(s => s.openCreateAgent); @@ -1164,6 +1161,12 @@ export const ChatInput: React.FC = ({ acpTargetAgentType, composerMode: currentMode, }); + const targetModeInfo = useMemo(() => { + const normalizedAgentType = effectiveSendAgentType.trim().toLowerCase(); + return normalizedAgentType + ? modeState.available.find(mode => mode.id.toLowerCase() === normalizedAgentType) ?? null + : null; + }, [effectiveSendAgentType, modeState.available]); const targetWorkspacePath = sessionBoundWorkspacePath; useEffect(() => { @@ -1216,14 +1219,14 @@ export const ChatInput: React.FC = ({ return; } - const targetAgentType = effectiveSendAgentType.trim(); - const targetModeInfo = targetAgentType - ? Array.from(modeInfoById.values()).find(mode => mode.id.toLowerCase() === targetAgentType.toLowerCase()) - : null; if (!targetModeInfo) { setTargetModeEnabledTools(null); return; } + if (targetModeInfo.source === 'external') { + setTargetModeEnabledTools(targetModeInfo.defaultTools ?? null); + return; + } let cancelled = false; setTargetModeEnabledTools(null); @@ -1247,7 +1250,7 @@ export const ChatInput: React.FC = ({ return () => { cancelled = true; }; - }, [effectiveSendAgentType, isSubagentInputTarget, modeInfoById]); + }, [isSubagentInputTarget, targetModeInfo]); const targetSkillToolAgents = useMemo(() => { const normalizedTargetAgentType = effectiveSendAgentType.trim().toLowerCase(); @@ -1696,7 +1699,7 @@ export const ChatInput: React.FC = ({ sessionMode: effectiveTargetSession?.mode, acpTargetAgentType, // Composer mode is authoritative for normal sessions (synced from session - // on switch, updated in applyModeChange). Subagent continuations keep the + // on switch, updated after an explicit mode change). Subagent continuations keep the // child session's own agent type instead of inheriting the parent composer. composerMode: modeState.current, }), @@ -2336,31 +2339,19 @@ export const ChatInput: React.FC = ({ }; }, [dispatchInput, inputState.isActive]); - React.useEffect(() => { - const fetchAvailableModes = async () => { - try { - const { agentAPI } = await import('@/infrastructure/api/service-api/AgentAPI'); - const modes = await agentAPI.getAvailableModes(); - dispatchMode({ type: 'SET_AVAILABLE_MODES', payload: modes }); - } catch (error) { - log.error('Failed to fetch available modes', { error }); - } - }; - - fetchAvailableModes(); - - const handleModeCatalogUpdated = () => { - fetchAvailableModes(); - }; - - globalEventBus.on('mode:config:updated', handleModeCatalogUpdated); - globalEventBus.on('custom-agent:updated', handleModeCatalogUpdated); - - return () => { - globalEventBus.off('mode:config:updated', handleModeCatalogUpdated); - globalEventBus.off('custom-agent:updated', handleModeCatalogUpdated); - }; - }, []); + const refreshWorkspaceModeCatalog = useWorkspaceModeCatalog( + { + workspacePath: targetWorkspacePath || undefined, + remoteConnectionId: + effectiveTargetSession?.remoteConnectionId || + effectiveTargetSession?.config.remoteConnectionId, + remoteSshHost: + effectiveTargetSession?.remoteSshHost || effectiveTargetSession?.config.remoteSshHost, + }, + modes => { + dispatchMode({ type: 'SET_AVAILABLE_MODES', payload: modes }); + }, + ); React.useEffect(() => { let cancelled = false; @@ -2383,26 +2374,6 @@ export const ChatInput: React.FC = ({ }; }, []); - React.useEffect(() => { - if (!userDefaultModeId || availableModeIds.size === 0) { - return; - } - - if (availableModeIds.has(userDefaultModeId)) { - return; - } - - setUserDefaultModeId(null); - void configAPI - .setConfig(DEFAULT_CHAT_INPUT_MODE_CONFIG_PATH, null) - .catch((error) => { - log.warn('Failed to clear unavailable default chat input mode preference', { - error, - modeId: userDefaultModeId, - }); - }); - }, [availableModeIds, userDefaultModeId]); - React.useEffect(() => { const handleSessionSwitched = (event: Event) => { const customEvent = event as CustomEvent<{ sessionId: string; mode: string }>; @@ -2448,9 +2419,9 @@ export const ChatInput: React.FC = ({ isAssistantWorkspace, availableModeCount: availableModeIds.size, }); - const applyModeChange = applyModeChangeRef.current; - if (applyModeChange) { - applyModeChange(nextMode); + const publishModeSelection = publishModeSelectionRef.current; + if (publishModeSelection) { + publishModeSelection(nextMode); } else { dispatchMode({ type: 'SET_CURRENT_MODE', payload: nextMode }); try { @@ -2848,10 +2819,10 @@ export const ChatInput: React.FC = ({ const externalCommands = getFilteredExternalPromptCommands(); const mcpPrompts = getFilteredMcpPromptCommands(); const skills = getFilteredSkills(); - let modeList = incrementalCodeModes; + let modeList = selectableCodeModes; if (canSwitchModes && slashCommandState.query) { const q = slashCommandState.query; - modeList = incrementalCodeModes.filter( + modeList = selectableCodeModes.filter( mode => mode.name.toLowerCase().includes(q) || mode.id.toLowerCase().includes(q) @@ -2863,7 +2834,7 @@ export const ChatInput: React.FC = ({ name: mode.name, })); return [...acpCommands, ...actions, ...externalCommands, ...mcpPrompts, ...modes, ...skills]; - }, [canSwitchModes, getFilteredActions, getFilteredAcpCommands, getFilteredExternalPromptCommands, getFilteredMcpPromptCommands, getFilteredSkills, incrementalCodeModes, isAcpInputSession, slashCommandState.query]); + }, [canSwitchModes, getFilteredActions, getFilteredAcpCommands, getFilteredExternalPromptCommands, getFilteredMcpPromptCommands, getFilteredSkills, isAcpInputSession, selectableCodeModes, slashCommandState.query]); const getActiveSlashPickerItems = useCallback((): SlashPickerItem[] => { if (slashCommandState.kind === 'actions') { @@ -3911,6 +3882,50 @@ export const ChatInput: React.FC = ({ const handleModelLoadingChange = useCallback((loading: boolean) => { setIsModelSwitching(loading); }, []); + + const publishSessionModeSelection = useCallback((modeId: string) => { + if (effectiveTargetSessionId) { + FlowChatStore.getInstance().updateSessionMode(effectiveTargetSessionId, modeId); + if (effectiveTargetSessionIdRef.current !== effectiveTargetSessionId) { + return; + } + } + dispatchMode({ + type: 'SET_CURRENT_MODE', + payload: modeId, + }); + + try { + sessionStorage.setItem('bitfun:flowchat:lastMode', modeId); + } catch { + // ignore + } + }, [effectiveTargetSessionId]); + + const sessionModeSelectionTarget = useMemo(() => effectiveTargetSessionId && effectiveTargetSession + ? { + sessionId: effectiveTargetSessionId, + workspacePath: sessionProjectWorkspacePath(effectiveTargetSession), + remoteConnectionId: + effectiveTargetSession.remoteConnectionId || + effectiveTargetSession.config.remoteConnectionId, + remoteSshHost: + effectiveTargetSession.remoteSshHost || effectiveTargetSession.config.remoteSshHost, + } + : null, [effectiveTargetSession, effectiveTargetSessionId]); + const reportModeSelectionFailure = useCallback((error: unknown, modeId: string) => { + log.error('Failed to update Session agent mode', { error, modeId }); + notificationService.error(t('chatInput.modeChangeFailed')); + }, [t]); + const { + isModeChangePending, + publishModeSelection, + requestModeChange: requestSessionModeChange, + } = useSessionModeSelection( + sessionModeSelectionTarget, + publishSessionModeSelection, + reportModeSelectionFailure, + ); const handleSendOrCancel = useCallback(async (messageOverride?: string) => { if (!derivedState) return; @@ -3928,7 +3943,7 @@ export const ChatInput: React.FC = ({ // Block sending while model switch IPC is in-flight — the backend session may // not yet reflect the newly selected model. - if (isModelSwitching) return; + if (isModelSwitching || isModeChangePending) return; if (sendButtonMode === 'retry') { await transition(SessionExecutionEvent.RESET); @@ -4124,6 +4139,7 @@ export const ChatInput: React.FC = ({ } }, [ isModelSwitching, + isModeChangePending, caps.transferInFlight, inputState.value, derivedState, @@ -4162,34 +4178,18 @@ export const ChatInput: React.FC = ({ composerMutationRevision, ]); - const getFilteredIncrementalModes = useCallback(() => { + const getFilteredSelectableModes = useCallback(() => { if (!canSwitchModes) return []; - if (!slashCommandState.query) return incrementalCodeModes; - return incrementalCodeModes.filter( + if (!slashCommandState.query) return selectableCodeModes; + return selectableCodeModes.filter( mode => mode.name.toLowerCase().includes(slashCommandState.query) || mode.id.toLowerCase().includes(slashCommandState.query) ); - }, [canSwitchModes, incrementalCodeModes, slashCommandState.query]); - - const applyModeChange = useCallback((modeId: string) => { - dispatchMode({ - type: 'SET_CURRENT_MODE', - payload: modeId, - }); - - try { - sessionStorage.setItem('bitfun:flowchat:lastMode', modeId); - } catch { - // ignore - } - - if (effectiveTargetSessionId) { - FlowChatStore.getInstance().updateSessionMode(effectiveTargetSessionId, modeId); - } - }, [effectiveTargetSessionId]); + }, [canSwitchModes, selectableCodeModes, slashCommandState.query]); - applyModeChangeRef.current = applyModeChange; + publishModeSelectionRef.current = publishModeSelection; + requestModeChangeRef.current = requestSessionModeChange; const requestModeChange = useCallback((modeId: string) => { if (!canSwitchModes) { @@ -4197,7 +4197,7 @@ export const ChatInput: React.FC = ({ return; } - if (modeId === currentMode) { + if (modeId === currentMode && !effectiveTargetSessionId) { dispatchMode({ type: 'CLOSE_DROPDOWN' }); return; } @@ -4207,9 +4207,9 @@ export const ChatInput: React.FC = ({ return; } - applyModeChange(modeId); + requestSessionModeChange(modeId); dispatchMode({ type: 'CLOSE_DROPDOWN' }); - }, [applyModeChange, canSwitchModes, currentMode, switchableModes]); + }, [canSwitchModes, currentMode, effectiveTargetSessionId, requestSessionModeChange, switchableModes]); const toggleDefaultMode = useCallback(async (modeId: string, modeName: string) => { const previousDefaultModeId = userDefaultModeId; @@ -4585,7 +4585,7 @@ export const ChatInput: React.FC = ({ if (e.key === 'Tab' && e.shiftKey) { const modes = switchableModesRef.current; const modeNow = currentModeRef.current; - const apply = applyModeChangeRef.current; + const apply = requestModeChangeRef.current; if (!(canSwitchModes && apply && modes.length > 1)) return; e.preventDefault(); @@ -4612,7 +4612,7 @@ export const ChatInput: React.FC = ({ if (!(slashCommandState.kind === 'modes' && !canSwitchModes)) { const items = slashCommandState.kind === 'modes' - ? getFilteredIncrementalModes() + ? getFilteredSelectableModes() : getActiveSlashPickerItems(); const maxIndex = Math.max(0, items.length - 1); @@ -4831,7 +4831,7 @@ export const ChatInput: React.FC = ({ e.preventDefault(); void handleCancelCurrentTask(); } - }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getActiveSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, canSwitchModes, getRichTextInlineTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); + }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, handleCancelCurrentTask, slashCommandState, getFilteredSelectableModes, getActiveSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, canSwitchModes, getRichTextInlineTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; @@ -4996,11 +4996,11 @@ export const ChatInput: React.FC = ({ if (sendButtonMode === 'retry') { return ( - + void handleSendOrCancel()} - disabled={isModelSwitching || caps.transferInFlight} + disabled={isModelSwitching || isModeChangePending || caps.transferInFlight} tooltip={t('input.retry')} size="small" > @@ -5026,11 +5026,11 @@ export const ChatInput: React.FC = ({ - + void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching || caps.transferInFlight} + disabled={!inputState.value.trim() || isModelSwitching || isModeChangePending || caps.transferInFlight} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} size="small" @@ -5043,11 +5043,11 @@ export const ChatInput: React.FC = ({ } return ( - + void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching || caps.transferInFlight} + disabled={!inputState.value.trim() || isModelSwitching || isModeChangePending || caps.transferInFlight} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} size="small" @@ -5448,7 +5448,7 @@ export const ChatInput: React.FC = ({ if (!canSwitchModes) return null; - const filteredModes = getFilteredIncrementalModes(); + const filteredModes = getFilteredSelectableModes(); return (
@@ -5501,6 +5501,9 @@ export const ChatInput: React.FC = ({ aria-expanded={modeState.dropdownOpen} onClick={e => { e.stopPropagation(); + if (!modeState.dropdownOpen) { + void refreshWorkspaceModeCatalog(); + } dispatchMode({ type: 'TOGGLE_DROPDOWN' }); }} > @@ -5530,7 +5533,7 @@ export const ChatInput: React.FC = ({ aria-label={t('chatInput.resetToAgentic')} onClick={e => { e.stopPropagation(); - applyModeChange('agentic'); + requestSessionModeChange('agentic'); dispatchMode({ type: 'CLOSE_DROPDOWN' }); }} > @@ -5545,8 +5548,8 @@ export const ChatInput: React.FC = ({ {canSwitchModes && ( <>
- {incrementalCodeModes.length > 0 && ( - incrementalCodeModes.map(modeOption => { + {selectableCodeModes.length > 0 && ( + selectableCodeModes.map(modeOption => { const modeDisabled = modeOption.id === 'ComputerUse' && !computerUseEnabled; const modeDescription = modeDisabled @@ -5828,6 +5831,8 @@ export const ChatInput: React.FC = ({ contextUsageSource={tokenUsage.source} onLoadingChange={handleModelLoadingChange} externalSelection={dispatchModelSelection} + modeDefaultModelId={targetModeInfo?.model} + persistSharedModeDefault={Boolean(targetModeInfo && targetModeInfo.source !== 'external')} />
) : null} diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index eff2f596e3..7cca92d7ba 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -68,6 +68,10 @@ interface ModelSelectorProps { onLoadingChange?: (loading: boolean) => void; /** Target-owned model catalog for transports that do not have a local backend session. */ externalSelection?: ExternalModelSelection; + /** Agent-profile model used only when the session has no explicit selection. */ + modeDefaultModelId?: string; + /** Whether a selection also changes BitFun's shared built-in mode default. */ + persistSharedModeDefault?: boolean; } interface ModelInfo { @@ -187,6 +191,8 @@ export const ModelSelector: React.FC = ({ contextUsageSource, onLoadingChange, externalSelection, + modeDefaultModelId, + persistSharedModeDefault = true, }) => { const { t } = useTranslation('flow-chat'); const [allModels, setAllModels] = useState([]); @@ -494,7 +500,7 @@ export const ModelSelector: React.FC = ({ // Legacy sessions created without a model selector fall back to the current // mode default until they are migrated by the send path. - const configuredModelId = modeModel; + const configuredModelId = modeDefaultModelId?.trim() || modeModel; if (configuredModelId === 'auto') return 'auto'; if (configuredModelId === 'primary' || configuredModelId === 'fast') { const actualModelId = defaultModels[configuredModelId]; @@ -503,7 +509,7 @@ export const ModelSelector: React.FC = ({ } const model = allModels.find(m => m.id === configuredModelId); return model ? configuredModelId : 'auto'; - }, [allModels, modeModel, defaultModels, activeSession?.config.modelName, targetIsSubagent]); + }, [allModels, modeDefaultModelId, modeModel, defaultModels, activeSession?.config.modelName, targetIsSubagent]); const currentModel = useMemo((): ModelInfo | null => { const modelId = getCurrentModelId(); @@ -630,13 +636,14 @@ export const ModelSelector: React.FC = ({ return; } - await configManager.setConfig('ai.agent_model_defaults.mode', modelId); - setModeModel(modelId); + if (persistSharedModeDefault) { + await configManager.setConfig('ai.agent_model_defaults.mode', modelId); + setModeModel(modelId); + globalEventBus.emit('mode:config:updated'); + } await updateTargetSessionModel(); log.info('Mode model updated', { mode: currentMode, modelId }); - - globalEventBus.emit('mode:config:updated'); } catch (error) { log.error('Failed to switch model', error); // Only a previously pinned selection can be restored: the store has no @@ -659,6 +666,7 @@ export const ModelSelector: React.FC = ({ externalSelection, isAcpSession, loading, + persistSharedModeDefault, sessionId, t, targetIsSubagent, diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx index 101a46e885..25c71c0828 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx @@ -6,6 +6,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ModelSelector } from './ModelSelector'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -126,4 +127,34 @@ describe('ModelSelector external transport reuse', () => { }); expect(onSelect).toHaveBeenCalledWith('model-b'); }); + + it('uses an external agent profile model without changing the shared mode default', async () => { + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + const trigger = container.querySelector( + '[data-testid="chat-model-selector-btn"]', + ); + expect(trigger?.textContent).toContain('friendly-model-a'); + await act(async () => { + trigger?.click(); + }); + + const auto = document.body.querySelector( + '[data-testid="chat-model-selector-option"][data-model-id="auto"]', + ); + await act(async () => { + auto?.click(); + await Promise.resolve(); + }); + expect(configManager.setConfig).not.toHaveBeenCalled(); + }); }); diff --git a/src/web-ui/src/flow_chat/hooks/useSessionModeSelection.test.tsx b/src/web-ui/src/flow_chat/hooks/useSessionModeSelection.test.tsx new file mode 100644 index 0000000000..9ad2b5ecea --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useSessionModeSelection.test.tsx @@ -0,0 +1,188 @@ +/** + * @vitest-environment jsdom + */ + +import { act, StrictMode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSessionModeSelection } from './useSessionModeSelection'; + +const mocks = vi.hoisted(() => ({ + updateSessionMode: vi.fn(), + publish: vi.fn(), + reportFailure: vi.fn(), +})); + +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ + agentAPI: { updateSessionMode: mocks.updateSessionMode }, +})); + +function Probe({ + sessionId = 'session-1', + publish = mocks.publish, +}: { + sessionId?: string; + publish?: (modeId: string) => void; +}) { + const selection = useSessionModeSelection( + { + sessionId, + workspacePath: 'D:/workspace/project', + }, + publish, + mocks.reportFailure, + ); + return ( + <> + {String(selection.isModeChangePending)} + + + + + ); +} + +describe('useSessionModeSelection', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mocks.updateSessionMode.mockResolvedValue(undefined); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + it('keeps passive hydration local and rebinds an explicit same-id selection', async () => { + await act(async () => { + root.render(); + }); + const [hydrate, select] = Array.from(container.querySelectorAll('button')); + + act(() => hydrate.click()); + expect(mocks.publish).toHaveBeenCalledWith('agentic'); + expect(mocks.updateSessionMode).not.toHaveBeenCalled(); + + await act(async () => { + select.click(); + await Promise.resolve(); + }); + expect(mocks.updateSessionMode).toHaveBeenCalledWith({ + sessionId: 'session-1', + modeId: 'agentic', + workspacePath: 'D:/workspace/project', + remoteConnectionId: undefined, + remoteSshHost: undefined, + }); + expect(mocks.publish).toHaveBeenLastCalledWith('agentic'); + }); + + it('publishes a completed request only to the session that issued it', async () => { + let resolveRequest: (() => void) | undefined; + mocks.updateSessionMode.mockImplementation(() => new Promise(resolve => { + resolveRequest = resolve; + })); + const publishA = vi.fn(); + const publishB = vi.fn(); + + await act(async () => { + root.render(); + }); + const select = Array.from(container.querySelectorAll('button'))[1]; + act(() => select.click()); + + await act(async () => { + root.render(); + }); + await act(async () => { + resolveRequest?.(); + await Promise.resolve(); + }); + + expect(publishA).toHaveBeenCalledWith('agentic'); + expect(publishB).not.toHaveBeenCalled(); + }); + + it('serializes a pending update and preserves the latest explicit selection', async () => { + const resolvers: Array<() => void> = []; + mocks.updateSessionMode.mockImplementation(() => new Promise(resolve => { + resolvers.push(resolve); + })); + + await act(async () => { + root.render(); + }); + const [, selectAgentic, selectAsk] = Array.from(container.querySelectorAll('button')); + act(() => { + selectAgentic.click(); + selectAsk.click(); + }); + expect(container.querySelector('[data-testid="pending"]')?.textContent).toBe('true'); + expect(mocks.updateSessionMode).toHaveBeenCalledTimes(1); + + await act(async () => { + resolvers[0](); + await Promise.resolve(); + }); + expect(mocks.updateSessionMode).toHaveBeenCalledTimes(2); + expect(mocks.updateSessionMode).toHaveBeenLastCalledWith(expect.objectContaining({ + sessionId: 'session-1', + modeId: 'ask', + })); + expect(mocks.publish).toHaveBeenCalledWith('agentic'); + + await act(async () => { + resolvers[1](); + await Promise.resolve(); + }); + expect(mocks.publish).toHaveBeenCalledTimes(2); + expect(mocks.publish).toHaveBeenLastCalledWith('ask'); + expect(container.querySelector('[data-testid="pending"]')?.textContent).toBe('false'); + }); + + it('keeps the last successful mode visible when a queued selection fails', async () => { + const resolvers: Array<() => void> = []; + const rejecters: Array<(error: Error) => void> = []; + mocks.updateSessionMode.mockImplementation(() => new Promise((resolve, reject) => { + resolvers.push(resolve); + rejecters.push(reject); + })); + + await act(async () => { + root.render(); + }); + const [, selectAgentic, selectAsk] = Array.from(container.querySelectorAll('button')); + act(() => { + selectAgentic.click(); + selectAsk.click(); + }); + await act(async () => { + resolvers[0](); + await Promise.resolve(); + }); + const failure = new Error('ask unavailable'); + await act(async () => { + rejecters[1](failure); + await Promise.resolve(); + }); + + expect(mocks.publish).toHaveBeenCalledOnce(); + expect(mocks.publish).toHaveBeenCalledWith('agentic'); + expect(mocks.reportFailure).toHaveBeenCalledWith(failure, 'ask'); + expect(container.querySelector('[data-testid="pending"]')?.textContent).toBe('false'); + }); +}); diff --git a/src/web-ui/src/flow_chat/hooks/useSessionModeSelection.ts b/src/web-ui/src/flow_chat/hooks/useSessionModeSelection.ts new file mode 100644 index 0000000000..830e906f4e --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useSessionModeSelection.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; + +export interface SessionModeSelectionTarget { + sessionId: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +interface ModeSelectionIntent { + request: SessionModeSelectionTarget & { modeId: string }; + publish: (modeId: string) => void; + reportFailure: (error: unknown, modeId: string) => void; +} + +interface SessionModeSelectionQueue { + next?: ModeSelectionIntent; +} + +/** + * Keep passive selector hydration separate from an explicit Runtime rebind. + * A catalog refresh may change which same-name route is currently offered, + * but only a user selection may replace a Session's durable route owner. + */ +export function useSessionModeSelection( + target: SessionModeSelectionTarget | null, + publishSelection: (modeId: string) => void, + reportFailure: (error: unknown, modeId: string) => void, +): { + isModeChangePending: boolean; + publishModeSelection: (modeId: string) => void; + requestModeChange: (modeId: string) => void; +} { + const publishRef = useRef(publishSelection); + const failureRef = useRef(reportFailure); + const sessionQueuesRef = useRef(new Map()); + const mountedRef = useRef(true); + const [, setQueueRevision] = useState(0); + publishRef.current = publishSelection; + failureRef.current = reportFailure; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const notifyQueueChanged = useCallback(() => { + if (mountedRef.current) { + setQueueRevision(revision => revision + 1); + } + }, []); + + const sessionId = target?.sessionId; + const workspacePath = target?.workspacePath; + const remoteConnectionId = target?.remoteConnectionId; + const remoteSshHost = target?.remoteSshHost; + + const publishModeSelection = useCallback((modeId: string) => { + publishRef.current(modeId); + }, []); + + const requestModeChange = useCallback((modeId: string) => { + if (!sessionId) { + publishRef.current(modeId); + return; + } + const intent: ModeSelectionIntent = { + request: { + sessionId, + modeId, + workspacePath, + remoteConnectionId, + remoteSshHost, + }, + publish: publishRef.current, + reportFailure: failureRef.current, + }; + const pendingQueue = sessionQueuesRef.current.get(sessionId); + if (pendingQueue) { + pendingQueue.next = intent; + return; + } + + const queue: SessionModeSelectionQueue = {}; + sessionQueuesRef.current.set(sessionId, queue); + notifyQueueChanged(); + const run = async (current: ModeSelectionIntent): Promise => { + let failed = false; + let failure: unknown; + try { + await agentAPI.updateSessionMode(current.request); + } catch (error) { + failed = true; + failure = error; + } + + if (!failed) { + current.publish(current.request.modeId); + } + + const next = queue.next; + queue.next = undefined; + if (next) { + await run(next); + return; + } + + sessionQueuesRef.current.delete(sessionId); + notifyQueueChanged(); + if (failed) { + current.reportFailure(failure, current.request.modeId); + } + }; + void run(intent); + }, [notifyQueueChanged, remoteConnectionId, remoteSshHost, sessionId, workspacePath]); + + return { + isModeChangePending: Boolean(sessionId && sessionQueuesRef.current.has(sessionId)), + publishModeSelection, + requestModeChange, + }; +} diff --git a/src/web-ui/src/flow_chat/hooks/useWorkspaceModeCatalog.test.tsx b/src/web-ui/src/flow_chat/hooks/useWorkspaceModeCatalog.test.tsx new file mode 100644 index 0000000000..1c61a08dbe --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useWorkspaceModeCatalog.test.tsx @@ -0,0 +1,135 @@ +/** + * @vitest-environment jsdom + */ + +import { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ModeInfo } from '../reducers/modeReducer'; +import { useWorkspaceModeCatalog } from './useWorkspaceModeCatalog'; + +const mocks = vi.hoisted(() => ({ + getAvailableModes: vi.fn(), + on: vi.fn(), + off: vi.fn(), +})); + +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ + agentAPI: { getAvailableModes: mocks.getAvailableModes }, +})); + +vi.mock('@/infrastructure/event-bus', () => ({ + globalEventBus: { on: mocks.on, off: mocks.off }, +})); + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ error: vi.fn() }), +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(resolver => { + resolve = resolver; + }); + return { promise, resolve }; +} + +function mode(id: string): ModeInfo { + return { + id, + name: id, + description: id, + isReadonly: false, + toolCount: 0, + promptCacheScopeKey: id, + source: 'external', + }; +} + +function Probe({ workspacePath }: { workspacePath: string }) { + const [modes, setModes] = useState([]); + const refresh = useWorkspaceModeCatalog({ workspacePath }, setModes); + return ( + <> + +
{modes.map(entry => entry.id).join(',')}
+ + ); +} + +describe('useWorkspaceModeCatalog', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + it('never publishes a late catalog from the previous workspace', async () => { + const workspaceA = deferred(); + const workspaceB = deferred(); + mocks.getAvailableModes.mockImplementation((request?: { workspacePath?: string }) => ( + request?.workspacePath === 'D:/workspace/A' ? workspaceA.promise : workspaceB.promise + )); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + root.render(); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="catalog"]')?.textContent).toBe(''); + + await act(async () => { + workspaceB.resolve([mode('workspace-b')]); + await workspaceB.promise; + }); + expect(container.querySelector('[data-testid="catalog"]')?.textContent).toBe('workspace-b'); + + await act(async () => { + workspaceA.resolve([mode('workspace-a')]); + await workspaceA.promise; + }); + expect(container.querySelector('[data-testid="catalog"]')?.textContent).toBe('workspace-b'); + }); + + it('refreshes on demand and keeps the newest same-workspace result', async () => { + const initial = deferred(); + const refreshed = deferred(); + mocks.getAvailableModes + .mockReturnValueOnce(initial.promise) + .mockReturnValueOnce(refreshed.promise); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + container.querySelector('button')?.click(); + await Promise.resolve(); + }); + await act(async () => { + refreshed.resolve([mode('refreshed')]); + await refreshed.promise; + }); + expect(container.querySelector('[data-testid="catalog"]')?.textContent).toBe('refreshed'); + + await act(async () => { + initial.resolve([mode('stale')]); + await initial.promise; + }); + expect(container.querySelector('[data-testid="catalog"]')?.textContent).toBe('refreshed'); + }); +}); diff --git a/src/web-ui/src/flow_chat/hooks/useWorkspaceModeCatalog.ts b/src/web-ui/src/flow_chat/hooks/useWorkspaceModeCatalog.ts new file mode 100644 index 0000000000..5cafc4c7e0 --- /dev/null +++ b/src/web-ui/src/flow_chat/hooks/useWorkspaceModeCatalog.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { globalEventBus } from '@/infrastructure/event-bus'; +import { createLogger } from '@/shared/utils/logger'; +import type { ModeInfo } from '../reducers/modeReducer'; + +const log = createLogger('WorkspaceModeCatalog'); + +/** + * Keep the selector catalog bound to the current workspace and newest refresh. + * External discovery can make an older request finish after a workspace switch + * or catalog update, so only the latest request may publish modes. + */ +export function useWorkspaceModeCatalog( + scope: { + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + }, + publishModes: (modes: ModeInfo[]) => void, +): () => Promise { + const { workspacePath, remoteConnectionId, remoteSshHost } = scope; + const publishModesRef = useRef(publishModes); + const mountedRef = useRef(false); + const requestSequenceRef = useRef(0); + publishModesRef.current = publishModes; + + const refresh = useCallback(async () => { + const requestId = ++requestSequenceRef.current; + try { + const { agentAPI } = await import('@/infrastructure/api/service-api/AgentAPI'); + const modes = await agentAPI.getAvailableModes({ + workspacePath: workspacePath || undefined, + remoteConnectionId: remoteConnectionId || undefined, + remoteSshHost: remoteSshHost || undefined, + }); + if (mountedRef.current && requestId === requestSequenceRef.current) { + publishModesRef.current(modes); + } + } catch (error) { + if (mountedRef.current && requestId === requestSequenceRef.current) { + log.error('Failed to fetch available modes', { error }); + } + } + }, [remoteConnectionId, remoteSshHost, workspacePath]); + + useEffect(() => { + mountedRef.current = true; + // Do not expose the previous workspace's local or external profiles while + // the new workspace-scoped catalog is still being discovered. + publishModesRef.current([]); + void refresh(); + globalEventBus.on('mode:config:updated', refresh); + globalEventBus.on('custom-agent:updated', refresh); + + return () => { + mountedRef.current = false; + requestSequenceRef.current += 1; + globalEventBus.off('mode:config:updated', refresh); + globalEventBus.off('custom-agent:updated', refresh); + }; + }, [refresh]); + + return refresh; +} diff --git a/src/web-ui/src/flow_chat/reducers/modeReducer.ts b/src/web-ui/src/flow_chat/reducers/modeReducer.ts index 5bb4c7e75d..7d3de1110b 100644 --- a/src/web-ui/src/flow_chat/reducers/modeReducer.ts +++ b/src/web-ui/src/flow_chat/reducers/modeReducer.ts @@ -14,6 +14,8 @@ export interface ModeInfo { * Modes with the same key can reuse the same session-level prompt cache. */ promptCacheScopeKey: string; + source?: 'builtin' | 'user' | 'project' | 'external'; + model?: string; } export interface ModeState { 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 8dcc631b67..b63bf7c613 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 @@ -342,7 +342,7 @@ describe('createChatSession', () => { expect(agentApiMocks.createSession).toHaveBeenCalledTimes(1); }); - it('snapshots the current mode model into a newly created session', async () => { + it('projects the runtime-resolved model for a newly created session', async () => { configManagerMocks.getConfigs.mockImplementation(async (paths: string[]) => { if (paths.length === 1 && paths[0] === 'ai.agent_model_defaults') { return { 'ai.agent_model_defaults': { mode: 'model-b' } }; @@ -356,13 +356,16 @@ describe('createChatSession', () => { const { context, flowChatStore } = createContext(createSession({ workspacePath: '/home/wsp/projects/Test', })); + agentApiMocks.createSession.mockResolvedValueOnce({ + sessionId: 'created-1', + modelId: 'model-b', + }); await createChatSession(context, { workspacePath: '/home/wsp/projects/Test' }, 'agentic'); expect(agentApiMocks.createSession).toHaveBeenCalledWith(expect.objectContaining({ config: expect.objectContaining({ - modelName: 'model-b', - maxContextTokens: 64000, + modelName: undefined, }), })); expect(flowChatStore.createSession).toHaveBeenCalledWith( @@ -391,6 +394,10 @@ describe('createChatSession', () => { const { context, flowChatStore } = createContext(createSession({ workspacePath: '/home/wsp/projects/Test', })); + agentApiMocks.createSession.mockResolvedValueOnce({ + sessionId: 'created-1', + modelId: 'model-a', + }); await createChatSession(context, { workspacePath: '/home/wsp/projects/Test', @@ -400,7 +407,6 @@ describe('createChatSession', () => { expect(agentApiMocks.createSession).toHaveBeenCalledWith(expect.objectContaining({ config: expect.objectContaining({ modelName: 'model-a', - maxContextTokens: 32000, }), })); expect(flowChatStore.createSession).toHaveBeenCalledWith( 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 213dbce8fa..3c24e73c23 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 @@ -15,7 +15,7 @@ import { i18nService } from '@/infrastructure/i18n'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; -import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; +import { isRemoteWorkspace, WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; import type { FlowChatContext, SessionConfig, @@ -507,7 +507,11 @@ export const resolveAgentTypeForSessionCreation = async ( return normalizedRequestedMode || 'agentic'; } - const availableModes = await agentAPI.getAvailableModes(); + const availableModes = await agentAPI.getAvailableModes({ + workspacePath: workspace?.rootPath, + remoteConnectionId: isRemoteWorkspace(workspace) ? workspace?.connectionId : undefined, + remoteSshHost: isRemoteWorkspace(workspace) ? workspace?.sshHost : undefined, + }); if (availableModes.some(mode => mode.id === configuredDefaultMode)) { return configuredDefaultMode; } 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 3db883bb70..2a0a9bf9a3 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 @@ -2,9 +2,10 @@ * Local session driver: the default flavor backed by this machine's (or the * attached peer's) agent runtime via `agentAPI`. * - * Bodies were moved verbatim from SessionModule/MessageModule; behavior is - * unchanged. Peer Device Mode is invisible here by design — it swaps the - * transport underneath `api.invoke`, so this driver must never consult it. + * Peer Device Mode is invisible here by design: it swaps the transport + * underneath `api.invoke`, so this driver must never consult it. Session + * creation sends only an explicit model selection and projects the Runtime's + * authoritative resolved model from the response. */ import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; @@ -32,7 +33,6 @@ import { } from '../../events/flowchatNavigation'; import { getModelMaxTokens, - resolveModelForSessionCreation, } from '../../utils/modelResolution'; import { syncSessionModelSelection } from '../../utils/modelSync'; import { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; @@ -63,13 +63,7 @@ export const localSessionDriver: SessionDriver = { remoteSshHost, } = seed; - const sessionModelName = await resolveModelForSessionCreation(config.modelName); - const maxContextTokens = await getModelMaxTokens(sessionModelName, agentType); - const mergedConfig: SessionConfig = { - ...config, - modelName: sessionModelName, - workspaceId: workspaceId ?? config.workspaceId, - }; + const explicitModelName = config.modelName?.trim() || undefined; const response = await agentAPI.createSession({ sessionName, @@ -78,21 +72,28 @@ export const localSessionDriver: SessionDriver = { projectWorkspacePath, executionTarget: config.executionTargetRequest, requestId: globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}-${Math.random()}`, - workspaceId: mergedConfig.workspaceId, + workspaceId: workspaceId ?? config.workspaceId, remoteConnectionId, remoteSshHost, config: { - modelName: sessionModelName, + modelName: explicitModelName, enableTools: true, safeMode: true, autoCompact: true, - maxContextTokens: maxContextTokens, enableContextCompression: true, remoteConnectionId, remoteSshHost, } }); + const sessionModelName = response.modelId ?? explicitModelName; + const maxContextTokens = await getModelMaxTokens(sessionModelName, agentType); + const mergedConfig: SessionConfig = { + ...config, + modelName: sessionModelName, + workspaceId: workspaceId ?? config.workspaceId, + }; + const effectiveWorkspacePath = response.workspacePath || response.executionTarget?.rootPath || workspacePath; const effectiveProjectWorkspacePath = diff --git a/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts b/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts index 9e4d70255f..fc556f4dd9 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts @@ -463,7 +463,7 @@ describe('resolveAvailableChatInputMode', () => { ).toBe('Plan'); }); - it('falls back to agentic when the current mode is no longer available', () => { + it('keeps a persisted unavailable session mode instead of silently replacing its route', () => { expect( resolveAvailableChatInputMode({ currentMode: 'PlannerPlus', @@ -471,10 +471,10 @@ describe('resolveAvailableChatInputMode', () => { sessionMode: 'PlannerPlus', availableModeIds: ['agentic', 'Team'], }), - ).toBe('agentic'); + ).toBeNull(); }); - it('keeps the current mode when only the session snapshot is stale', () => { + it('restores the persisted session mode even while its catalog entry is unavailable', () => { expect( resolveAvailableChatInputMode({ currentMode: 'Team', @@ -482,7 +482,18 @@ describe('resolveAvailableChatInputMode', () => { sessionMode: 'PlannerPlus', availableModeIds: ['agentic', 'Team'], }), - ).toBeNull(); + ).toBe('PlannerPlus'); + }); + + it('restores the persisted session mode while the workspace catalog is loading', () => { + expect( + resolveAvailableChatInputMode({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'WorkspaceProfile', + availableModeIds: [], + }), + ).toBe('WorkspaceProfile'); }); it('keeps assistant workspaces pinned to Claw when available', () => { @@ -507,7 +518,7 @@ describe('resolveAvailableChatInputMode', () => { ).toBe('Claw'); }); - it('falls back to the first available mode when agentic is unavailable', () => { + it('does not replace a persisted mode with the first unrelated available mode', () => { expect( resolveAvailableChatInputMode({ currentMode: 'PlannerPlus', @@ -515,7 +526,7 @@ describe('resolveAvailableChatInputMode', () => { sessionMode: 'PlannerPlus', availableModeIds: ['Team', 'Plan'], }), - ).toBe('Team'); + ).toBeNull(); }); it('uses the user default mode when starting from the internal project default', () => { diff --git a/src/web-ui/src/flow_chat/utils/chatInputMode.ts b/src/web-ui/src/flow_chat/utils/chatInputMode.ts index e75865912b..a1d083462b 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputMode.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputMode.ts @@ -337,17 +337,27 @@ export function resolveAvailableChatInputMode(params: { const availableModeIds = new Set( Array.from(params.availableModeIds, (modeId) => modeId.trim()).filter(Boolean), ); + const normalizedSessionMode = params.sessionMode?.trim(); + const synchronizedMode = resolveWorkspaceChatInputMode(params); + + // A persisted standard-Session mode is an execution binding, not a catalog + // suggestion. If its source is temporarily unavailable, retain the logical + // id and let the backend's durable route owner fail closed. Only an explicit + // user selection may replace it. Assistant workspaces keep their product + // rule below, where Claw remains authoritative. + if (!params.isAssistantWorkspace && normalizedSessionMode) { + return synchronizedMode; + } + if (availableModeIds.size === 0) { return null; } - const synchronizedMode = resolveWorkspaceChatInputMode(params); if (synchronizedMode && availableModeIds.has(synchronizedMode)) { return synchronizedMode; } const normalizedCurrentMode = params.currentMode.trim(); - const normalizedSessionMode = params.sessionMode?.trim(); const normalizedUserDefaultModeId = normalizeUserDefaultChatInputModeId(params.userDefaultModeId); const effectiveUserDefaultModeId = normalizedUserDefaultModeId && availableModeIds.has(normalizedUserDefaultModeId) diff --git a/src/web-ui/src/flow_chat/utils/modelResolution.ts b/src/web-ui/src/flow_chat/utils/modelResolution.ts index 0fda729bc8..c056497662 100644 --- a/src/web-ui/src/flow_chat/utils/modelResolution.ts +++ b/src/web-ui/src/flow_chat/utils/modelResolution.ts @@ -22,7 +22,6 @@ function findEnabledModel(models: AIModelConfig[], modelRef: string | null | und && (model.id === value || model.name === value || model.model_name === value) ) ?? null; } - function resolveModelForContextWindow( modelRef: string | null | undefined, models: AIModelConfig[], @@ -89,20 +88,3 @@ export async function getModelMaxTokens(modelName?: string, agentType?: string): return 128128; } } - -export async function resolveModelForSessionCreation(modelName?: string): Promise { - const explicitModelName = modelName?.trim(); - if (explicitModelName) { - return explicitModelName; - } - - try { - const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); - const configData = await configManager.getConfigs(['ai.agent_model_defaults']); - const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; - return agentModelDefaults?.mode?.trim() || 'auto'; - } catch (error) { - log.warn('Failed to resolve model default during session creation', { error }); - return 'auto'; - } -} diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts index ad457f022d..82b7b056b4 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts @@ -16,6 +16,42 @@ describe('AgentAPI', () => { invokeMock.mockResolvedValue(undefined); }); + it('preserves remote identity when querying the workspace mode catalog', async () => { + invokeMock.mockResolvedValueOnce([]); + + await agentAPI.getAvailableModes({ + workspacePath: 'D:/remote/repo', + remoteConnectionId: 'remote-1', + remoteSshHost: 'build-host', + }); + + expect(invokeMock).toHaveBeenCalledWith('get_available_modes', { + request: { + workspacePath: 'D:/remote/repo', + remoteConnectionId: 'remote-1', + remoteSshHost: 'build-host', + }, + }); + }); + + it('sends an explicit Session mode rebind through the typed selector request', async () => { + await agentAPI.updateSessionMode({ + sessionId: 'session-1', + modeId: 'reviewer', + workspacePath: 'D:/workspace/BitFun', + remoteConnectionId: 'remote-1', + }); + + expect(invokeMock).toHaveBeenCalledWith('update_session_mode', { + request: { + sessionId: 'session-1', + modeId: 'reviewer', + workspacePath: 'D:/workspace/BitFun', + remoteConnectionId: 'remote-1', + }, + }); + }); + it('loads a bounded Session Turn window through a structured request', async () => { invokeMock.mockResolvedValueOnce({ status: 'ready', diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 5ef20aa8c5..9091740310 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -73,6 +73,7 @@ export interface CreateSessionResponse { sessionId: string; sessionName: string; agentType: string; + modelId?: string; workspacePath?: string; workspaceId?: string; projectWorkspacePath?: string; @@ -310,6 +311,15 @@ export interface UpdateSessionModelRequest { includeInternal?: boolean; } +export interface UpdateSessionModeRequest { + sessionId: string; + modeId: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + includeInternal?: boolean; +} + export type AgentContextReloadTarget = 'all' | 'skills' | 'instructions'; export interface AgentContextReloadRequest { @@ -951,6 +961,14 @@ export class AgentAPI { } } + async updateSessionMode(request: UpdateSessionModeRequest): Promise { + try { + await api.invoke('update_session_mode', { request }); + } catch (error) { + throw createTauriCommandError('update_session_mode', error, request); + } + } + async reloadSessionContext( request: AgentContextReloadRequest, ): Promise { @@ -1338,9 +1356,15 @@ export class AgentAPI { - async getAvailableModes(): Promise { + async getAvailableModes(request: { + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + } = {}): Promise { try { - return await api.invoke('get_available_modes'); + return await api.invoke('get_available_modes', { + request, + }); } catch (error) { throw createTauriCommandError('get_available_modes', error); } diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts index 8275866a7b..066b986d20 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.test.ts @@ -3,6 +3,7 @@ import { ExternalSourceApiError, externalSourcesAPI } from './ExternalSourcesAPI import { webSocketResponseError } from '../adapters/websocket-adapter'; import { PeerProductCommandError } from '../adapters/peer-device-adapter'; import { ApiClient } from './ApiClient'; +import { globalEventBus } from '@/infrastructure/event-bus'; const invokeMock = vi.hoisted(() => vi.fn()); const adapterMocks = vi.hoisted(() => ({ @@ -434,6 +435,9 @@ describe('ExternalSourcesAPI', () => { }); it('sends policy scope and optimistic revision as one atomic mutation', async () => { + const catalogUpdated = vi.fn(); + const unsubscribe = globalEventBus.on('mode:config:updated', catalogUpdated); + await externalSourcesAPI.updateIntegrationPolicy('D:/workspace/project', { expectedPreferenceRevision: 8, scope: 'workspace', @@ -460,6 +464,11 @@ describe('ExternalSourcesAPI', () => { }, }, }); + expect(catalogUpdated).toHaveBeenCalledWith({ + reason: 'external-agent-catalog-updated', + workspacePath: 'D:/workspace/project', + }); + unsubscribe(); }); it('normalizes typed host errors without matching user-visible strings', async () => { @@ -571,7 +580,7 @@ describe('ExternalSourcesAPI', () => { ); }); - it('normalizes omitted subagent model-binding collections at the API boundary', async () => { + it('normalizes legacy subagent role and model-binding fields at the API boundary', async () => { invokeMock.mockResolvedValue(surface({ generation: 2, discoveryPending: false, @@ -602,6 +611,7 @@ describe('ExternalSourcesAPI', () => { expect(result.subagentModelBindingGroups).toEqual([]); expect(result.subagentModelBindingOptions).toEqual([]); expect(result.subagents?.[0]).toMatchObject({ + mode: 'subagent', requestedModel: { kind: 'default' }, modelBindingMethod: 'default', }); diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index 0554ff026e..690966392f 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -1,4 +1,5 @@ import { api } from './ApiClient'; +import { globalEventBus } from '@/infrastructure/event-bus'; export type ExternalSourceScope = | 'user_global' @@ -287,6 +288,7 @@ export interface ExternalSubagentSummary { sourceKeys: Array<{ providerId: string; sourceId: string }>; sourceLocationLabels: string[]; sourceCount: number; + mode: 'primary' | 'subagent' | 'all'; requestedModel: ExternalSubagentModelRequest; requestedModelProfile?: ExternalSubagentModelProfileRequest; modelBindingMethod: ExternalSubagentModelBindingMethod; @@ -992,6 +994,7 @@ function normalizeSnapshot(value: unknown): ExternalSourceCatalogSnapshot { })), subagents: normalizeOptionalArray(candidate.subagents).map((subagent) => ({ ...subagent, + mode: subagent.mode ?? 'subagent', requestedModel: subagent.requestedModel ?? { kind: 'default' }, modelBindingMethod: subagent.modelBindingMethod ?? 'default', sourceKeys: normalizeOptionalArray(subagent.sourceKeys), @@ -1311,6 +1314,13 @@ function controlRequest( }; } +function emitExternalAgentCatalogUpdated(workspacePath?: string) { + globalEventBus.emit('mode:config:updated', { + reason: 'external-agent-catalog-updated', + workspacePath: normalizeOptionalWorkspacePath(workspacePath), + }); +} + export const externalSourcesAPI = { planMcpImport(workspacePath?: string) { return invokeExternalSourceCommand( @@ -1469,12 +1479,13 @@ export const externalSourcesAPI = { ), }, }); + emitExternalAgentCatalogUpdated(workspacePath); return surface.catalog; } catch (error) { if (!(error instanceof ExternalSourceApiError) || error.code !== 'incompatible_version') { throw error; } - return invokeSnapshot('set_external_source_enabled_command', { + const catalog = await invokeSnapshot('set_external_source_enabled_command', { request: { workspacePath: normalizedWorkspacePath, sourceKey, @@ -1482,6 +1493,8 @@ export const externalSourcesAPI = { expectedPreferenceRevision, }, }); + emitExternalAgentCatalogUpdated(workspacePath); + return catalog; } }, @@ -1552,7 +1565,7 @@ export const externalSourcesAPI = { }); }, - setSubagentActivation( + async setSubagentActivation( workspacePath: string | undefined, candidateId: string, approved: boolean, @@ -1560,7 +1573,7 @@ export const externalSourcesAPI = { expectedPreferenceRevision: number, decisionKey: string, ) { - return invokeSnapshot('set_external_subagent_activation_command', { + const catalog = await invokeSnapshot('set_external_subagent_activation_command', { request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath), candidateId, @@ -1570,16 +1583,18 @@ export const externalSourcesAPI = { decisionKey, }, }); + emitExternalAgentCatalogUpdated(workspacePath); + return catalog; }, - setSubagentModelBinding( + async setSubagentModelBinding( workspacePath: string | undefined, bindingKey: string, target: ExternalSubagentModelBindingTarget | undefined, expectedSubagentGeneration: number, expectedPreferenceRevision: number, ) { - return invokeSnapshot('set_external_subagent_model_binding_command', { + const catalog = await invokeSnapshot('set_external_subagent_model_binding_command', { request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath), bindingKey, @@ -1588,9 +1603,11 @@ export const externalSourcesAPI = { expectedPreferenceRevision, }, }); + emitExternalAgentCatalogUpdated(workspacePath); + return catalog; }, - chooseSubagentConflict( + async chooseSubagentConflict( workspacePath: string | undefined, conflictKey: string, candidateId: string, @@ -1598,7 +1615,7 @@ export const externalSourcesAPI = { expectedSubagentGeneration: number, expectedPreferenceRevision: number, ) { - return invokeSnapshot('choose_external_subagent_conflict_command', { + const catalog = await invokeSnapshot('choose_external_subagent_conflict_command', { request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath), conflictKey, @@ -1608,6 +1625,8 @@ export const externalSourcesAPI = { expectedPreferenceRevision, }, }); + emitExternalAgentCatalogUpdated(workspacePath); + return catalog; }, setMcpServerDecision( @@ -1650,13 +1669,15 @@ export const externalSourcesAPI = { }); }, - updateIntegrationPolicy( + async updateIntegrationPolicy( workspacePath: string | undefined, mutation: ExternalIntegrationPolicyMutation, ) { - return invokeSnapshot( + const catalog = await invokeSnapshot( 'update_external_integration_policy_command', { request: { workspacePath: normalizeOptionalWorkspacePath(workspacePath), mutation } }, ); + emitExternalAgentCatalogUpdated(workspacePath); + return catalog; }, }; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 252227cf61..7db67d9258 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -1464,7 +1464,7 @@ describe('ExternalSourcesConfig', () => { const details = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('common.details')); await act(async () => details?.click()); - expect(container.textContent).toContain('agents.singleRun'); + expect(container.textContent).toContain('agents.role.subagent'); expect(container.textContent).toContain('fast'); expect(container.textContent).toContain('Read, Grep'); expect(container.textContent).toContain('agents.executionDomain'); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 179b7ec812..f913c8bb90 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -2497,7 +2497,7 @@ const ExternalSourcesConfig: React.FC = () => {
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 5689c6700f..9d08bbc0c0 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -680,6 +680,7 @@ } }, "addBoostTooltip": "Agent modes, image, or skills", + "modeChangeFailed": "Failed to change the agent mode. Try again.", "permissionMode": { "menuLabel": "Permission mode", "globalScope": "Global", diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index c261082c14..fab5c93320 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -248,7 +248,11 @@ "enable": "Enable agent", "reviewTitle": "Review how {{name}} runs", "approvalWarning": "Once enabled, this agent can instruct the selected model to use the tools shown above. BitFun asks again if its instructions, model, tools, or configuration sources change.", - "singleRun": "One run only; no follow-up", + "role": { + "primary": "Main sessions", + "subagent": "One-run tasks; no follow-up", + "all": "Main sessions and one-run tasks" + }, "modelUnavailable": "Unavailable", "modelResolvedFromParentAtTaskStart": "Resolved from the parent session when the task starts", "requestedModel": "Requested by source: {{model}}", 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 fda9e5a86e..c45ae4f672 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -680,6 +680,7 @@ } }, "addBoostTooltip": "智能体模式、图片或 Skill", + "modeChangeFailed": "切换智能体模式失败,请重试。", "permissionMode": { "menuLabel": "权限模式", "globalScope": "全局", diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index b24c167d0f..8116a23cae 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -248,7 +248,11 @@ "enable": "启用 Agent", "reviewTitle": "查看 {{name}} 的运行设置", "approvalWarning": "启用后,该 Agent 可以让所选模型调用上方工具。其指令、模型、工具或配置来源发生变化时,BitFun 会要求你再次确认。", - "singleRun": "单次运行,不支持续问", + "role": { + "primary": "主会话", + "subagent": "单次任务,不支持续问", + "all": "主会话和单次任务" + }, "modelUnavailable": "不可用", "modelResolvedFromParentAtTaskStart": "任务开始时从父会话解析", "requestedModel": "来源声明:{{model}}", 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 570c704f0f..16c4b9b490 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -680,6 +680,7 @@ } }, "addBoostTooltip": "智能體模式、圖片或 Skill", + "modeChangeFailed": "切換智能體模式失敗,請重試。", "permissionMode": { "menuLabel": "權限模式", "globalScope": "全域", diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index ad03dfb203..7f865abf49 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -248,7 +248,11 @@ "enable": "啟用 Agent", "reviewTitle": "查看 {{name}} 的執行設定", "approvalWarning": "啟用後,此 Agent 可以讓所選模型呼叫上方工具。其指令、模型、工具或設定來源變更時,BitFun 會要求你再次確認。", - "singleRun": "單次執行,不支援追問", + "role": { + "primary": "主工作階段", + "subagent": "單次任務,不支援追問", + "all": "主工作階段和單次任務" + }, "modelUnavailable": "無法使用", "modelResolvedFromParentAtTaskStart": "任務開始時從父工作階段解析", "requestedModel": "來源宣告:{{model}}",