feat: configure supervisor model and story graph use per conversation
Add a supervisor text-model selector using existing connections and a per-conversation story graph switch above knowledge settings. Hide the switch when Supervisor is disabled and enforce conversation preferences at local and remote tool execution boundaries. Release note: Choose a dedicated supervisor model and control whether each conversation can query existing stories and experiences. Enabling graph access does not start a review or automatically insert context.
This commit is contained in:
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
监督者统一工作回顾、会话监督与故事线图谱,以智能心跳作为定时唤醒机制。现有智能心跳页面承载监督者工作回顾入口,手动回顾、定时结果投影、来源原文、实体/关系确认、继续讨论、知识库预览提交和侧栏反馈已经接入。历史回顾定位到对应结果图谱;侧栏按会话或任务查询,并支持固定目标。同 scope 的跨 run 实体复用依赖模型显式选择 Main 提供的候选引用,Main 处理 UUID 和旧文本主键的精确映射,具体规则和验证边界见[实施进度](./progress.md)。
|
监督者统一工作回顾、会话监督与故事线图谱,以智能心跳作为定时唤醒机制。现有智能心跳页面承载监督者工作回顾入口,手动回顾、定时结果投影、来源原文、实体/关系确认、继续讨论、知识库预览提交和侧栏反馈已经接入。历史回顾定位到对应结果图谱;侧栏按会话或任务查询,并支持固定目标。同 scope 的跨 run 实体复用依赖模型显式选择 Main 提供的候选引用,Main 处理 UUID 和旧文本主键的精确映射,具体规则和验证边界见[实施进度](./progress.md)。
|
||||||
|
|
||||||
|
监督者可在“设置 → 模型”选择已有文本模型或跟随应用默认,连接与凭据复用规则及执行期间的模型一致性见[监督者模型选择](./logic-design.md#监督者模型选择)。
|
||||||
|
|
||||||
## 文档导航
|
## 文档导航
|
||||||
|
|
||||||
主页面包含工作回顾、故事线图谱、自动监督、活动记录和设置。工作回顾阅读统一监督结果并选择历史,图谱定位同一结果;自动监督管理计划、监督建议和只读的旧报告。活动记录展示统一执行、进度、建议阶段及详情,可按计划 ID 筛选;设置只保留算法参数。当前布局以 [UI 设计](./ui-design.md#应用入口)为准。活动读取实际手动回顾及心跳执行,合并展示同一次心跳触发的监督回顾与建议阶段;状态规则、历史缺失和保留期限见[活动记录](./logic-design.md#活动记录)。
|
主页面包含工作回顾、故事线图谱、自动监督、活动记录和设置。工作回顾阅读统一监督结果并选择历史,图谱定位同一结果;自动监督管理计划、监督建议和只读的旧报告。活动记录展示统一执行、进度、建议阶段及详情,可按计划 ID 筛选;设置管理模型选择和算法参数。当前布局以 [UI 设计](./ui-design.md#应用入口)为准。活动读取实际手动回顾及心跳执行,合并展示同一次心跳触发的监督回顾与建议阶段;状态规则、历史缺失和保留期限见[活动记录](./logic-design.md#活动记录)。
|
||||||
|
|
||||||
心跳的职责为“定时触发增量回顾+按配置介入(建议)”,不再先生成独立报告;手动“回顾”默认增量,历史重看改为显式“重新整理”。规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计),需求 FR-S12,验收 US-S32、US-S33,2026-09-30 已实现(主动介入除外),验证见[实施进度](./progress.md#2026-09-30-心跳职责收敛与监督建议)。
|
心跳的职责为“定时触发增量回顾+按配置介入(建议)”,不再先生成独立报告;手动“回顾”默认增量,历史重看改为显式“重新整理”。规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计),需求 FR-S12,验收 US-S32、US-S33,2026-09-30 已实现(主动介入除外),验证见[实施进度](./progress.md#2026-09-30-心跳职责收敛与监督建议)。
|
||||||
|
|
||||||
@@ -28,4 +30,4 @@
|
|||||||
|
|
||||||
实施状态以代码和[实施进度](./progress.md)为准。逻辑待定项见[逻辑设计](./logic-design.md),架构边界见[技术设计](./technical-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。
|
实施状态以代码和[实施进度](./progress.md)为准。逻辑待定项见[逻辑设计](./logic-design.md),架构边界见[技术设计](./technical-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。
|
||||||
|
|
||||||
2026-09-28:Agent 可按需通过三个只读 MCP 工具读取决定、演变和来源;监督者关闭后不暴露工具并拒绝旧会话调用。本地与远程接线及验证边界见[实施进度](./progress.md#2026-09-28-story-graph-只读工具)。当前 memory 读取、精确 scope 增量进度和候选身份保留;跨 scope 叶子复用、读取入口切换及 `as_of` 仍属后续能力。
|
Agent 可按需通过三个只读 MCP 工具读取决定、演变和来源。会话设置中的“使用故事图谱”默认开启,仅在监督者应用启用时显示;关闭会话开关或应用后不暴露工具并拒绝旧会话调用。规则见 [MCP 接入合同](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入),验证边界见[实施进度](./progress.md)。当前 memory 读取、精确 scope 增量进度和候选身份保留;跨 scope 叶子复用、读取入口切换及 `as_of` 仍属后续能力。
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
本文承接[监督者产品设计](./supervisor-prd.md)、[故事线图谱设计](../story-graph/prd.md)和[User Stories](./user-stories.md),定义触发、整理、展示与用户后续操作之间的关系。既有会话监督的证据和介入边界继续由[会话监督 PRD](./prd.md)约束。
|
本文承接[监督者产品设计](./supervisor-prd.md)、[故事线图谱设计](../story-graph/prd.md)和[User Stories](./user-stories.md),定义触发、整理、展示与用户后续操作之间的关系。既有会话监督的证据和介入边界继续由[会话监督 PRD](./prd.md)约束。
|
||||||
|
|
||||||
|
## 监督者模型选择
|
||||||
|
|
||||||
|
对应 FR-S13。监督者可跟随应用默认文本模型,或固定使用一个已保存的文本模型连接;会话和项目的 Runtime 选择不影响它。提取、导航汇总、故事归属、经验整理及建议措辞均使用监督者的模型设置。
|
||||||
|
|
||||||
|
回顾获得执行位置后,读取一次连接配置;同次执行中的批次、输出重试、汇总、故事与经验阶段保持该模型。修改监督者选择、应用默认模型或连接配置,仅影响后续执行。继续暂停或失败的回顾、单独重试故事、生成或重试建议时重新读取当前选择;跨重启不保存模型或凭据快照。显式选择的连接被删除、改为图片协议或缺少所需 API Key 时,执行报错并提示修改设置,不自动换成默认模型。
|
||||||
|
|
||||||
## 整体逻辑架构
|
## 整体逻辑架构
|
||||||
|
|
||||||
报告、监督叶子与导航整理共用监督专用 FIFO 并发池,普通聊天不入池;监督服务外层 run 仍串行。报告在排队前冻结超时,获槽后领取,实际模型请求前校验并刷新租约;报告提交或无变化后先释放槽位,再执行监督。监督超时在 run 创建时保存,节点获槽并解析 Runtime 后计时。阶段超时、租约、传输时限、后台检查与活动轮询各自独立,取值及生效规则见[当前设置合同](./review-scheduling-design.md#当前设置合同)。
|
报告、监督叶子与导航整理共用监督专用 FIFO 并发池,普通聊天不入池;监督服务外层 run 仍串行。报告在排队前冻结超时,获槽后领取,实际模型请求前校验并刷新租约;报告提交或无变化后先释放槽位,再执行监督。监督超时在 run 创建时保存,节点获槽并解析 Runtime 后计时。阶段超时、租约、传输时限、后台检查与活动轮询各自独立,取值及生效规则见[当前设置合同](./review-scheduling-design.md#当前设置合同)。
|
||||||
@@ -215,6 +221,8 @@ flowchart LR
|
|||||||
|
|
||||||
## 完整性与待定项
|
## 完整性与待定项
|
||||||
|
|
||||||
|
FR-S11、US-S30 的会话故事图谱开关规则见 [MCP 接入合同第 6 节](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入)。应用启用、会话允许和 Runtime 能力分配须同时满足;回顾是否自动运行不由会话读取开关控制。
|
||||||
|
|
||||||
FR-S11、US-S28 至 US-S31 的规则由[时态 Story Graph MCP 设计](./story-graph-mcp-design.md)统一定义:时间/确认与有效性见第 3 节,读取范围与分页见第 4 节,应用关闭与 Runtime 接入见第 6 节,跨 scope 复用与记忆过渡见第 7、8 节。D1 读取已接入,后两项仍待实现;Global/项目独立 checkpoint 和同 scope 候选身份保留。MCP 不接受 `as_of`,较晚生成的结果也不自动使旧决定失效。
|
FR-S11、US-S28 至 US-S31 的规则由[时态 Story Graph MCP 设计](./story-graph-mcp-design.md)统一定义:时间/确认与有效性见第 3 节,读取范围与分页见第 4 节,应用关闭与 Runtime 接入见第 6 节,跨 scope 复用与记忆过渡见第 7、8 节。D1 读取已接入,后两项仍待实现;Global/项目独立 checkpoint 和同 scope 候选身份保留。MCP 不接受 `as_of`,较晚生成的结果也不自动使旧决定失效。
|
||||||
|
|
||||||
本设计覆盖触发、证据输入、静默检查、正常整理、取消与失败、跨视图共享结果、用户纠正、知识库写入及历史回放。它补充产品逻辑,不代表接口、持久化或迁移方案已经完成。
|
本设计覆盖触发、证据输入、静默检查、正常整理、取消与失败、跨视图共享结果、用户纠正、知识库写入及历史回放。它补充产品逻辑,不代表接口、持久化或迁移方案已经完成。
|
||||||
|
|||||||
@@ -4,6 +4,34 @@
|
|||||||
|
|
||||||
当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。
|
当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。
|
||||||
|
|
||||||
|
## 2026-10-03 会话故事图谱开关
|
||||||
|
|
||||||
|
FR-S11、US-S30 的“使用故事图谱”已接通 Composer、生产 Preload/IPC、SQLite 和工具网关。会话默认开启,仅在监督者应用启用时显示,位置在知识库之前。存储和执行合同见 [MCP 接入](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入),没有数据库版本升级或迁移。另保留本轮开始前已有的监督者独立模型选择及并行性能改动。
|
||||||
|
|
||||||
|
定向测试共 9 个文件、37 项通过,重复运行不重复计数:
|
||||||
|
|
||||||
|
- `npx vitest run src/main/agent/native-client-coordinator.test.ts src/renderer/src/conversation-persistence.test.ts src/renderer/src/Composer.test.tsx src/agent-daemon/story-graph-integration.test.ts`:25 项。覆盖默认值、按会话隔离、保存中及失败、原生客户端绑定、Main/Agent HTTP MCP 发现、旧调用拒绝与重新开启;图谱集成 5 项在最后修改后单独复跑通过,含在途结果交付前关闭。
|
||||||
|
- `npx vitest run src/main/assistant/assistant-database.test.ts src/main/ipc.test.ts src/renderer/src/App.test.tsx src/main/agent/deepseek-harness-acp-e2e.test.ts -t "story graph|Story Graph|pins"`:11 项通过,630 项按名称未运行。覆盖真实 SQLite 重开、旧自动保存不能覆盖选择、IPC 校验、四种本地 Runtime 的签发与关闭,以及受控 Harness Ask/Execute。
|
||||||
|
- `npx vitest run tests/overlay-layering.electron.test.ts -t "conversation story graph"`:1 项通过,3 项按名称未运行。真实 App、Preload、IPC 和 SQLite 在 1280px/390px、浅色/深色四种组合下检查共享 Switch、位置和可见矩形;原生鼠标打开菜单、Space 保存、页面重载、应用关闭时移除 DOM、重开后恢复选择及旧工具绑定拒绝均通过。切换没有模型请求,模型尝试计数为 0。
|
||||||
|
|
||||||
|
`npm run typecheck` 的 Main、Agent、Web 三组通过,`git diff --check` 通过。修改文件定向 ESLint 除 `conversation-selectors.ts:32,47` 外通过;这两处是同工作树并行标题缓存改动的 `react-hooks/immutability` 报错,本功能只改该文件的 Composer 字段投影,未修改缓存逻辑。没有执行全量测试、发布构建、提交或推送。中文 MCP 说明扫描阻断项为 0,复核项保留必要的数据和执行边界。
|
||||||
|
|
||||||
|
### 本轮 Linux Host 证据
|
||||||
|
|
||||||
|
[源码探针](../../../scripts/story-graph-host-probe.ts)临时打包后在共享 Linux x64 Host 运行当前 `AgentImageToolMcp`、`AgentOwnedAcpPrompt`、`ContinueHostAdapter`,桌面使用真实 `AssistantDatabase`、`KnowledgeMcpGateway` 和 `MainImageToolSession`。关闭操作改为写会话持久设置,验证其他会话仍开启,以及同一个远程 MCP 会话不再发现工具、旧工具调用被拒绝。
|
||||||
|
|
||||||
|
OpenCode 和 Continue 的 Ask/Execute 确定性探针通过,共 10 次测试推理请求(含 2 次标题生成)、10 次桌面图谱读取、0 次外部 Provider 请求。真实 DeepSeek 复测中,Linux OpenCode Ask/Execute 均完成图谱调用并返回完成标记,随后关闭检查通过;该轮 6 次 Provider 请求(含 2 次标题生成)、8 次桌面读取。首轮真实模型已完成 Ask 图谱读取,但探针未拼接流式文字便检查完成标记而失败,消耗 3 次 Provider 请求;修正探针后复测,整个任务外部请求合计 **9 次**。
|
||||||
|
|
||||||
|
工具消息通过 SSH 标准输入输出搬运,调用生产二进制工具分发;没有重装共享 Agent,也未把完整 Desktop attach/bootstrap 或远端 Continue 的真实模型请求计作已验证。远程协议与模型桥源码未变,会话开关由 Desktop 的现有可用性回调执行。测试只使用隔离 SQLite 和固定示例内容;临时 Host 探针及运行目录已清理。Electron 界面与 SSH Runtime 分开验收,没有宣称一条自动化场景覆盖整窗点击到远端模型。
|
||||||
|
|
||||||
|
## 2026-10-03 监督者独立模型选择
|
||||||
|
|
||||||
|
FR-S13 已接通监督者“设置 → 模型”、应用设置持久化和 Main 连接解析。字段只保存已有文本连接 UUID,旧配置跟随应用默认;执行中复用同一 Runtime,继续和重试采用当前设置。规则见[模型选择](./logic-design.md#监督者模型选择)。
|
||||||
|
|
||||||
|
定向验证覆盖中英文选择与保存失败草稿、失效连接提示、旧设置默认值及重载、提取到经验阶段的模型一致性、失败后的释放与继续、建议措辞连接及清理。`ipc.test.ts` 的监督/心跳相关 24 项通过,含真实直连 Runtime 到 loopback HTTP 服务的两次请求:固定连接、应用默认各一次,验证模型名、请求定制、无工具及失效连接不回退。外部真实模型请求为 0 次。
|
||||||
|
|
||||||
|
验证命令:`npx vitest run src/main/application-settings-store.test.ts src/main/assistant/supervision-production.test.ts src/renderer/src/HeartbeatCenter.test.tsx`;`npx vitest run src/main/ipc.test.ts -t "supervis|heartbeat|unchanged sources"`;`npm run typecheck`;修改的源文件定向 ESLint。未跑全量测试、打包、真实供应商或 Electron 原生界面测试。监督模型请求在桌面 Main 内完成,Agent 和远程模型桥未改动;未进行远程 Host 验证。
|
||||||
|
|
||||||
## 2026-10-02 长会话验收与 Agent 自主调用图谱
|
## 2026-10-02 长会话验收与 Agent 自主调用图谱
|
||||||
|
|
||||||
### 长会话验收
|
### 长会话验收
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
|
|||||||
|
|
||||||
- 真实验证覆盖一个 27 条消息的完整会话;较早的 129 条消息实验在第二次输出 JSON 无效后停止。两实验合计 5 次付费请求,没有重复付费或回退。范围及缓存复核说明见[进度](./progress.md#2026-09-24-完整会话生产验证与收尾)。
|
- 真实验证覆盖一个 27 条消息的完整会话;较早的 129 条消息实验在第二次输出 JSON 无效后停止。两实验合计 5 次付费请求,没有重复付费或回退。范围及缓存复核说明见[进度](./progress.md#2026-09-24-完整会话生产验证与收尾)。
|
||||||
- 来源被修改或删除时保留旧批次并明确失败,标记需要新建回顾,活动页停止提供无效的继续按钮;下一次自动检查可创建新 run,不会永久重复该失败。尚未实现仅使受影响叶子和父节点失效后重算。清单记录冻结版本,不保存全库正文快照。
|
- 来源被修改或删除时保留旧批次并明确失败,标记需要新建回顾,活动页停止提供无效的继续按钮;下一次自动检查可创建新 run,不会永久重复该失败。尚未实现仅使受影响叶子和父节点失效后重算。清单记录冻结版本,不保存全库正文快照。
|
||||||
- 分块配置、超时、接纳并发及算法版本已保存。实际默认 Runtime、模型及候选背景仍在执行时解析;未实现跨重启模型/提示词版本固定,也未实现共享池排空后切换冻结并发的目标规则。
|
- 分块配置、超时、接纳并发及算法版本已保存。监督者模型连接在每次执行开始时解析,同次回顾复用;继续或重试读取当前选择,规则见[监督者模型选择](./logic-design.md#监督者模型选择)。候选背景仍在执行时读取;未实现跨重启模型/提示词版本固定,也未实现共享池排空后切换冻结并发的目标规则。
|
||||||
- 元数据清单和最终发布仍是同步 SQLite 事务,缺少大库延迟验证及可中断来源读取。批次详情已分页,既有历史图谱查询仍返回整份结果;大图谱的响应大小尚未改造。
|
- 元数据清单和最终发布仍是同步 SQLite 事务,缺少大库延迟验证及可中断来源读取。批次详情已分页,既有历史图谱查询仍返回整份结果;大图谱的响应大小尚未改造。
|
||||||
- 没有 token/费用预算、提供商 RPM/TPM、Retry-After 冷却、执行硬期限、独立清理期限或逐层可配置超时。默认正文预算不保证任意模型上下文容量;超长输出、非法 JSON 和引用错误会失败,保留已成功批次。
|
- 没有 token/费用预算、提供商 RPM/TPM、Retry-After 冷却、执行硬期限、独立清理期限或逐层可配置超时。默认正文预算不保证任意模型上下文容量;超长输出、非法 JSON 和引用错误会失败,保留已成功批次。
|
||||||
- 片段以码点边界切分,尚未增加句界优先和重叠背景。事实保留指保留模型已产出的结构化记录,不证明语义无遗漏;生产契约校验引用 token,不要求原型的逐字引文和 reviewedTokens 回执。提供过的 locator.source 仅在本节点唯一对应一个片段时可作为引用别名;歧义及未知来源拒绝。
|
- 片段以码点边界切分,尚未增加句界优先和重叠背景。事实保留指保留模型已产出的结构化记录,不证明语义无遗漏;生产契约校验引用 token,不要求原型的逐字引文和 reviewedTokens 回执。提供过的 locator.source 仅在本节点唯一对应一个片段时可作为引用别名;歧义及未知来源拒绝。
|
||||||
|
|||||||
@@ -112,12 +112,17 @@ D1 来源页默认 4,000、上限 8,000 码点。`current` 通过既有 locator
|
|||||||
| 配置或目录 | 当前状态 | 目标映射 |
|
| 配置或目录 | 当前状态 | 目标映射 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `heartbeatEnabled` | 监督者应用开关,新设置默认关闭 | 为 Story Graph 暴露和每次调用的必要条件;关闭后不列出三个工具,旧会话直接调用也拒绝 |
|
| `heartbeatEnabled` | 监督者应用开关,新设置默认关闭 | 为 Story Graph 暴露和每次调用的必要条件;关闭后不列出三个工具,旧会话直接调用也拒绝 |
|
||||||
|
| 会话 `storyGraphEnabled` | 每个会话默认开启,缺省字段按开启处理 | 关闭后不向该会话签发图谱工具,并拒绝已有能力会话的发现、调用和在途结果交付;其他会话不受影响 |
|
||||||
| 内置能力 `enabled` 与 `assignments` | [CapabilityService](../../../src/main/capabilities/capability-service.ts)管理现有 MCP 开关与 Runtime 分配 | `story-graph` 沿用同一管理方式;应用关闭时不得由内置能力开关绕过,保留用户原分配 |
|
| 内置能力 `enabled` 与 `assignments` | [CapabilityService](../../../src/main/capabilities/capability-service.ts)管理现有 MCP 开关与 Runtime 分配 | `story-graph` 沿用同一管理方式;应用关闭时不得由内置能力开关绕过,保留用户原分配 |
|
||||||
| 内置工具目录 | 已有 Story Graph 条目 | 映射 `story_graph_search`、`story_graph_get_context`、`story_graph_read_source`,全部 `access: read`、`readOnlyHint: true` |
|
| 内置工具目录 | 已有 Story Graph 条目 | 映射 `story_graph_search`、`story_graph_get_context`、`story_graph_read_source`,全部 `access: read`、`readOnlyHint: true` |
|
||||||
| 监督整理参数 | 超时、并发、来源页与批次设置已存在 | 控制后台整理;不让一次 MCP 查询启动整理,也不以正文批次预算截断检索结果 |
|
| 监督整理参数 | 超时、并发、来源页与批次设置已存在 | 控制后台整理;不让一次 MCP 查询启动整理,也不以正文批次预算截断检索结果 |
|
||||||
| 计划 `enabled` | 决定自动回顾是否到期执行 | 不决定已有图谱能否读取;应用开启且能力可用时,无计划也能查已有结果 |
|
| 计划 `enabled` | 决定自动回顾是否到期执行 | 不决定已有图谱能否读取;应用开启且能力可用时,无计划也能查已有结果 |
|
||||||
|
|
||||||
Main 在工具发现和调用分发时共同检查应用启用、能力启用、Runtime 分配和 scope;只隐藏菜单不足以阻止旧会话调用。关闭后拒绝新的读取请求,已在途响应交付前复核状态,避免旧绑定继续返回数据。启用工具本身不产生回顾或数据写入。Ask 与 Execute 均可读取,同样没有写工具,也不借此扩大 Execute 权限。
|
Main 在工具发现和调用分发时共同检查应用启用、会话开关、能力启用、Runtime 分配和 scope;只隐藏菜单不足以阻止旧会话调用。关闭后拒绝新的读取请求,已在途响应交付前复核状态,避免旧绑定继续返回数据。启用工具本身不产生回顾或图谱写入。Ask 与 Execute 均可读取,同样没有写工具,也不借此扩大 Execute 权限。
|
||||||
|
|
||||||
|
会话开关只决定 Agent 能否按需使用这三个 MCP 工具,不自动查询或注入图谱上下文,也不触发回顾。监督者应用关闭时,输入区完全隐藏开关并保留会话选择;再次启用应用后恢复该选择。后台回顾和计划仍由各自设置控制。
|
||||||
|
|
||||||
|
开关复用已发布的 `conversations.context_state_json`,字段为可选布尔值,不增加数据库版本或迁移。Renderer 经串行会话保存队列与 `conversations:set-story-graph` IPC 保存,成功后更新显示,失败保留原值并通知。Main 按会话主键只读设置,不加载消息历史;显式设置写入后,旧的自动保存快照不能覆盖它。新会话及新建分支使用默认开启。
|
||||||
|
|
||||||
本地 OpenCode、Continue 和原生客户端复用 [KnowledgeMcpGateway](../../../src/main/agent/knowledge-mcp-gateway.ts) 的 MCP 注册和分发;直连 Model 与 DeepSeek Harness Main 代理调用同一服务。远程 OpenCode/Continue 复用现有图像工具的受管 HTTP MCP 和二进制通道,`storyGraph` 描述独立于图像生成描述,Ask 可只携带图谱读取。请求仍由桌面 Main 解析范围和读取 SQLite,不把数据库、正文库或凭据复制到 Host。
|
本地 OpenCode、Continue 和原生客户端复用 [KnowledgeMcpGateway](../../../src/main/agent/knowledge-mcp-gateway.ts) 的 MCP 注册和分发;直连 Model 与 DeepSeek Harness Main 代理调用同一服务。远程 OpenCode/Continue 复用现有图像工具的受管 HTTP MCP 和二进制通道,`storyGraph` 描述独立于图像生成描述,Ask 可只携带图谱读取。请求仍由桌面 Main 解析范围和读取 SQLite,不把数据库、正文库或凭据复制到 Host。
|
||||||
|
|
||||||
@@ -162,4 +167,4 @@ D1 可将现有已确认偏好作为明确标记的背景投影,原读取路
|
|||||||
| Runtime 一致性 | US-S30 | 本地及 GoodBuddy Agent 远程实际链路具有相同 scope、版本、分页、引用与关闭行为,不能以共享 schema 代替接线验收 |
|
| Runtime 一致性 | US-S30 | 本地及 GoodBuddy Agent 远程实际链路具有相同 scope、版本、分页、引用与关闭行为,不能以共享 schema 代替接线验收 |
|
||||||
| 记忆及范围复用 | US-S31 | 确认偏好与已有引用保留;同版本成功叶子跨 scope 复用且能检索事实,不只返回摘要;历史缺口不补造 |
|
| 记忆及范围复用 | US-S31 | 确认偏好与已有引用保留;同版本成功叶子跨 scope 复用且能检索事实,不只返回摘要;历史缺口不补造 |
|
||||||
|
|
||||||
验证命令和证据集中记录在[实施进度](./progress.md#2026-09-28-story-graph-只读工具)。本次只运行定向测试、类型检查、限定文件 lint 和确定性 Host 探针,没有全量测试、付费模型请求或发布操作。真实模型如何根据冲突证据形成最终建议,仍需与工具读取正确性分开验收。
|
2026-09-28 的 D1 验证命令和证据记录在[实施进度](./progress.md#2026-09-28-story-graph-只读工具),当时运行定向测试、类型检查、限定文件 lint 和确定性 Host 探针,没有付费模型请求。2026-10-03 会话开关的 Electron、Host 及真实模型调用证据见[本轮记录](./progress.md#2026-10-03-会话故事图谱开关)。真实模型如何根据冲突证据形成最终建议,仍需与工具读取正确性分开验收。
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ FR-S1:将现有智能心跳入口整合、升级为监督者,不增加另一
|
|||||||
|
|
||||||
应用默认关闭,用户显式启用后才接受新的回顾工作;关闭时保留计划和历史。默认值、已保存选择、自动运行条件及在途运行边界由[应用启停与执行](./logic-design.md#应用启停与执行)统一定义。
|
应用默认关闭,用户显式启用后才接受新的回顾工作;关闭时保留计划和历史。默认值、已保存选择、自动运行条件及在途运行边界由[应用启停与执行](./logic-design.md#应用启停与执行)统一定义。
|
||||||
|
|
||||||
FR-S2:应用包含“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”五个主视图。工作回顾提供统一监督结果阅读、历史选择和手动新回顾,结果显示自身范围与日期;自动监督集中展示计划状态、范围、周期及下次执行时间,以及运行概览、指标、趋势、自动监督报告、待处理记忆与行动建议和心跳审计,创建和编辑使用同一份 Modal 表单;活动记录展示统一实际执行、进度、详情及结果入口,支持按计划 ID 筛选;设置只管理超时、并发、分页、分批及单次响应容量,不嵌套次级菜单。计划保存失败保留草稿,关闭未保存修改须确认;编辑已暂停计划保持暂停,创建须显式保存并启用。原有运行记录及应用、存储标识保留。
|
FR-S2:应用包含“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”五个主视图。工作回顾提供统一监督结果阅读、历史选择和手动新回顾,结果显示自身范围与日期;自动监督集中展示计划状态、范围、周期及下次执行时间,以及运行概览、指标、趋势、自动监督报告、待处理记忆与行动建议和心跳审计,创建和编辑使用同一份 Modal 表单;活动记录展示统一实际执行、进度、详情及结果入口,支持按计划 ID 筛选;设置管理模型选择、超时、并发、分页、分批及单次响应容量,分类页签见 UI 设计。计划保存失败保留草稿,关闭未保存修改须确认;编辑已暂停计划保持暂停,创建须显式保存并启用。原有运行记录及应用、存储标识保留。
|
||||||
|
|
||||||
当前设置还支持分别调整报告与监督整理的模型超时,以及两者共用的监督并发数。并发设置不限制普通聊天,也不保证避免上游限流;排队不消耗模型阶段时限,降低并发不打断在途请求。取值及生效规则由[当前控制清单](./review-scheduling-design.md#当前超时与调度控制清单已实现)定义。
|
当前设置还支持分别调整报告与监督整理的模型超时,以及两者共用的监督并发数。并发设置不限制普通聊天,也不保证避免上游限流;排队不消耗模型阶段时限,降低并发不打断在途请求。取值及生效规则由[当前控制清单](./review-scheduling-design.md#当前超时与调度控制清单已实现)定义。
|
||||||
|
|
||||||
|
FR-S13:监督者的“设置 → 模型”可选择跟随应用默认文本模型或已有文本模型连接,复用连接及其凭据。回顾执行中修改设置不切换模型;生效时点、继续和重试规则见[监督者模型选择](./logic-design.md#监督者模型选择)。
|
||||||
|
|
||||||
FR-S10:活动同时覆盖手动监督回顾、定时心跳及用户立即运行的心跳。每条记录显示触发方式、范围、开始与结束时间、运行状态、失败原因和已有结果入口。同一次心跳的报告和下游监督回顾合为一条活动,报告保存成功不能掩盖下游仍在运行、暂停或失败。活动读取不调用模型,关闭应用后不查询活动。新批次运行支持暂停及继续,保留成功批次;人工修改的时间序列审计尚未实现,历史缺失不得猜测补齐。
|
FR-S10:活动同时覆盖手动监督回顾、定时心跳及用户立即运行的心跳。每条记录显示触发方式、范围、开始与结束时间、运行状态、失败原因和已有结果入口。同一次心跳的报告和下游监督回顾合为一条活动,报告保存成功不能掩盖下游仍在运行、暂停或失败。活动读取不调用模型,关闭应用后不查询活动。新批次运行支持暂停及继续,保留成功批次;人工修改的时间序列审计尚未实现,历史缺失不得猜测补齐。
|
||||||
|
|
||||||
FR-S2:分页只控制数据库读取批量,不能限制最终处理范围。当前已开放来源页大小、每批正文/消息上限;这些配置在新建回顾时保存,继续沿用。单次响应容量按当前设置生效。回顾持续处理至完成,不按累计运行时长自动暂停。当前字段和未来高级参数的边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界),交互见 [UI 设计](./ui-design.md#算法配置设置目标)。完整覆盖、来源校验与事实保留不作为可关闭选项。
|
FR-S2:分页只控制数据库读取批量,不能限制最终处理范围。当前已开放来源页大小、每批正文/消息上限;这些配置在新建回顾时保存,继续沿用。单次响应容量按当前设置生效。回顾持续处理至完成,不按累计运行时长自动暂停。当前字段和未来高级参数的边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界),交互见 [UI 设计](./ui-design.md#算法配置设置目标)。完整覆盖、来源校验与事实保留不作为可关闭选项。
|
||||||
@@ -59,6 +61,8 @@ FR-S11(D1 已实现,D2/D3 待实现):监督者以时态 Story Graph
|
|||||||
|
|
||||||
触发、整理与结果共享规则,以及整体架构和图谱对象关系见[逻辑设计](./logic-design.md)。
|
触发、整理与结果共享规则,以及整体架构和图谱对象关系见[逻辑设计](./logic-design.md)。
|
||||||
|
|
||||||
|
FR-S11 的会话控制:输入区对话设置在知识库上方提供“使用故事图谱”,每个会话默认开启并独立保存。仅在监督者应用启用时显示,应用关闭时完全隐藏。关闭会话开关后,本地及远程 Agent 不再获得图谱工具,已有能力会话调用也被拒绝;开启仅允许 Agent 自主决定是否调用 MCP,不注入图谱上下文或触发回顾。验收见 US-S30。
|
||||||
|
|
||||||
首个完整流程为:启用监督者、选择范围、生成回顾、进入图谱、核对来源、携带选定上下文继续讨论。知识库双向定位与内容补充随后接入同一流程。图谱关系辅助检索需单独验证效果,不作为首版默认检索策略。
|
首个完整流程为:启用监督者、选择范围、生成回顾、进入图谱、核对来源、携带选定上下文继续讨论。知识库双向定位与内容补充随后接入同一流程。图谱关系辅助检索需单独验证效果,不作为首版默认检索策略。
|
||||||
|
|
||||||
当前工作回顾和图谱读取生产结果,活动展示实际执行与无变化检查、暂停和继续入口,自动监督设置复用已有计划。完整图谱回放及应用市场尚未实现,验收证据见[实施进度](./progress.md)。
|
当前工作回顾和图谱读取生产结果,活动展示实际执行与无变化检查、暂停和继续入口,自动监督设置复用已有计划。完整图谱回放及应用市场尚未实现,验收证据见[实施进度](./progress.md)。
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。
|
本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。
|
||||||
|
|
||||||
FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用及 Runtime 分配。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;无 schema 迁移。该文独立定义对象/版本合同、时间语义、配置映射及后续跨 scope 复用和记忆过渡。当前 memory 背景读取、精确 scope checkpoint 与候选身份保留;结果快照不支持 `as_of` 历史重建。
|
FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用、会话开关及 Runtime 分配。绑定携带会话 ID,按主键读取当前设置;原生客户端的复用键包含会话 ID 和开关值。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;会话开关复用 `context_state_json`,无 schema 迁移。该文独立定义对象/版本合同、时间语义、配置映射及后续跨 scope 复用和记忆过渡。当前 memory 背景读取、精确 scope checkpoint 与候选身份保留;结果快照不支持 `as_of` 历史重建。
|
||||||
|
|
||||||
生产入口由 `supervision-production.ts` 组装服务、SQLite、共享池和 ask Runtime。`supervision-review-store.ts` 保存冻结来源清单,按有界页和片段供 `SupervisorService` 派发;叶子及连续位置先提交,导航和完整结果之后发布。消息已有知识引用保留本地或外部 locator,已确认记忆通过独立背景输入提供。不会检索外部全库。详细存储、调度及当前限制统一见[分块调度第 0 节](./review-scheduling-design.md#0-生产接线与剩余边界)。
|
生产入口由 `supervision-production.ts` 组装服务、SQLite、共享池和 ask Runtime。`supervision-review-store.ts` 保存冻结来源清单,按有界页和片段供 `SupervisorService` 派发;叶子及连续位置先提交,导航和完整结果之后发布。消息已有知识引用保留本地或外部 locator,已确认记忆通过独立背景输入提供。不会检索外部全库。详细存储、调度及当前限制统一见[分块调度第 0 节](./review-scheduling-design.md#0-生产接线与剩余边界)。
|
||||||
|
|
||||||
@@ -41,6 +41,12 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、
|
|||||||
|
|
||||||
已确认记忆作为当前背景,不参与新增正文覆盖。最多四条、每条 500 字符,时间来自真实更新时间;模型不能把背景引用为本批新增证据。旧结果继续保留原来源类型和 locator。监督正文上限现用于分批,余段继续处理;心跳报告的有界输入语义没有改变。
|
已确认记忆作为当前背景,不参与新增正文覆盖。最多四条、每条 500 字符,时间来自真实更新时间;模型不能把背景引用为本批新增证据。旧结果继续保留原来源类型和 locator。监督正文上限现用于分批,余段继续处理;心跳报告的有界输入语义没有改变。
|
||||||
|
|
||||||
|
## 监督者模型连接
|
||||||
|
|
||||||
|
`ApplicationSettings.supervisorModelProfileId` 只保存已有连接 UUID,`null` 或旧设置缺少字段时跟随 `RuntimeSettings.defaultModelProfileId`。Main 的 `resolveSupervisorRuntime` 从 `getResolvedSettings()` 读取连接和已有凭据,校验文本协议及认证后,用 `createModelProfileRuntime` 创建无工具直连 Runtime。Renderer 仅接收公开连接信息,不新增凭据存储或 IPC 方法。
|
||||||
|
|
||||||
|
`SupervisorService.admit` 在执行位置内调用生产工厂的 `withExecution`,为提取、导航、故事及经验共享一个 Runtime;执行结束后释放。每次请求仍释放自身临时会话。建议措辞在自身调用开始时解析同一设置,结束后释放 Runtime。生命周期规则见[监督者模型选择](./logic-design.md#监督者模型选择)。这条路径在桌面 Main 内运行,即使证据来自远程项目,也不经过 GoodBuddy Agent 或远程模型桥。
|
||||||
|
|
||||||
## 模型阶段超时与调度
|
## 模型阶段超时与调度
|
||||||
|
|
||||||
监督整理超时由 run 创建时冻结;共享模型池仍采用实时设置上限。`supervision:pause` 取消该 run 的排队和在途工作,`supervision:resume` 继续已保存批次;监督整理输出在流中按 `supervisionReview.responseKiB` 检查响应容量,默认 1024 KiB,可在设置页调整为 100 至 16384 KiB。该容量每次请求读取当前设置,继续旧运行也采用新值;超限保留成功批次并提示调高后继续。生成摘要及事实内容不设独立短字符限制,监督旧摘要读取保留全文,具体边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。以下报告领取和租约规则继续适用。
|
监督整理超时由 run 创建时冻结;共享模型池仍采用实时设置上限。`supervision:pause` 取消该 run 的排队和在途工作,`supervision:resume` 继续已保存批次;监督整理输出在流中按 `supervisionReview.responseKiB` 检查响应容量,默认 1024 KiB,可在设置页调整为 100 至 16384 KiB。该容量每次请求读取当前设置,继续旧运行也采用新值;超限保留成功批次并提示调高后继续。生成摘要及事实内容不设独立短字符限制,监督旧摘要读取保留全文,具体边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。以下报告领取和租约规则继续适用。
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
## 应用入口
|
## 应用入口
|
||||||
|
|
||||||
|
“设置 → 模型”还提供“监督者模型”单选框:默认“跟随应用默认”,其余选项为已保存的文本模型连接,以连接名称和模型名区分,不列图片模型。失效的已选连接保留“所选模型已不可用”,用户可改选;加载或保存期间禁用选择。沿用该分类的保存按钮、未保存草稿保护和失败重试,常显说明覆盖的阶段及[生效时点](./logic-design.md#监督者模型选择),不增加凭据输入。
|
||||||
|
|
||||||
应用中心中的智能心跳入口升级为“监督者”,沿用既有启用、常驻与排序能力。未来应用市场使用同一应用身份。应用已启用时,停用单条自动心跳计划仍可手动回顾;关闭应用后,已打开页面显示关闭状态并禁用工作区操作。默认值及运行边界见[应用启停与执行](./logic-design.md#应用启停与执行)。
|
应用中心中的智能心跳入口升级为“监督者”,沿用既有启用、常驻与排序能力。未来应用市场使用同一应用身份。应用已启用时,停用单条自动心跳计划仍可手动回顾;关闭应用后,已打开页面显示关闭状态并禁用工作区操作。默认值及运行边界见[应用启停与执行](./logic-design.md#应用启停与执行)。
|
||||||
|
|
||||||
当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”(英文活动标签为 Activity records)。智能心跳独立管理心跳计划;设置不含计划、报告或审计,也不显示整页全局徽标或独立刷新行。设置内用一排分段页签按用途分为“模型 / 回顾整理 / 故事与经验 / 建议”,每次只显示一个表单:模型含整理超时与并发;回顾整理含读取条数、每批文本量与消息数、单次响应容量;故事与经验含子线索与经验阈值及跨项目关联开关;建议含暂无进展天数。各分类单独保存,只提交本分类字段并沿用其他字段的已存值;切换分类保留未保存的草稿。故事线图谱没有数据时,空状态占满可见高度,标题、说明与“工作回顾 / 刷新”按钮水平、垂直居中,不再在上方单独放刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。
|
当前应用页的顶层导航只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”(英文活动标签为 Activity records)。智能心跳独立管理心跳计划;设置不含计划、报告或审计,也不显示整页全局徽标或独立刷新行。设置内用一排分段页签按用途分为“模型 / 回顾整理 / 故事与经验 / 建议”,每次只显示一个表单:模型含整理超时与并发;回顾整理含读取条数、每批文本量与消息数、单次响应容量;故事与经验含子线索与经验阈值及跨项目关联开关;建议含暂无进展天数。各分类单独保存,只提交本分类字段并沿用其他字段的已存值;切换分类保留未保存的草稿。故事线图谱没有数据时,空状态占满可见高度,标题、说明与“工作回顾 / 刷新”按钮水平、垂直居中,不再在上方单独放刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。故事线图谱面板内另有分类页签,见[故事线图谱](#故事线图谱)。
|
||||||
@@ -12,6 +14,12 @@
|
|||||||
|
|
||||||
新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用;重复点击发起只重新显示当前运行提示,不排入新回顾。状态条提供活动记录入口,清除计划筛选并聚焦活动页签;右侧 X 只关闭提示,不暂停或取消执行,同一次运行的轮询不重新打开已关闭提示。范围、时间和活动计划筛选保留可访问名称,不再重复显示字段标题。工作回顾不读取历史心跳报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时,空态独占工具栏下方整行,标题与手动回顾入口水平居中;自动报告是否为空不影响此判断。运行中和暂停后使用独立状态条,提示当前状态及活动记录入口。
|
新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用;重复点击发起只重新显示当前运行提示,不排入新回顾。状态条提供活动记录入口,清除计划筛选并聚焦活动页签;右侧 X 只关闭提示,不暂停或取消执行,同一次运行的轮询不重新打开已关闭提示。范围、时间和活动计划筛选保留可访问名称,不再重复显示字段标题。工作回顾不读取历史心跳报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时,空态独占工具栏下方整行,标题与手动回顾入口水平居中;自动报告是否为空不影响此判断。运行中和暂停后使用独立状态条,提示当前状态及活动记录入口。
|
||||||
|
|
||||||
|
## 会话故事图谱开关
|
||||||
|
|
||||||
|
对应 FR-S11、US-S30。输入区“选项 → 对话设置”在知识库之前显示“使用故事图谱”(英文 `Use story graph`),复用共享 `toggle-row` 滑动 Switch,标签在左、开关在右,支持 Tab 和 Space。仅当监督者应用 `heartbeatEnabled` 为 true 时渲染;应用关闭或设置尚未加载时完全隐藏,不保留禁用控件或占位。
|
||||||
|
|
||||||
|
每个会话默认开启。保存期间保留已确认状态并禁用重复提交,成功后显示新值,失败时保留原值并通过应用通知说明错误。切换会话、重新打开或重启后恢复各自设置。开关的执行规则和持久化见 [MCP 接入合同](./story-graph-mcp-design.md#6-内置-mcp-与-runtime-接入)。
|
||||||
|
|
||||||
## 活动
|
## 活动
|
||||||
|
|
||||||
文案遵循根目录 [监督者文案规则](../../../UI-DESIGN.md#134-智能心跳):不把内部路线图、交付状态或实现缺项展示给用户。活动页不常显重复说明;真实错误、失败和暂停状态仍展示,并保留恢复操作。事实详情提示展开批次并对照原文核对模型内容,不承诺完整语义识别;图谱说明实体状态以所选回顾结果为准。
|
文案遵循根目录 [监督者文案规则](../../../UI-DESIGN.md#134-智能心跳):不把内部路线图、交付状态或实现缺项展示给用户。活动页不常显重复说明;真实错误、失败和暂停状态仍展示,并保留恢复操作。事实详情提示展开批次并对照原文核对模型内容,不承诺完整语义识别;图谱说明实体状态以所选回顾结果为准。
|
||||||
|
|||||||
@@ -615,6 +615,11 @@
|
|||||||
| 用户进行普通聊天 | 没有继续工作或历史依据需求 | 不强制每轮查询或注入完整图谱;需要核对原话时才进一步读来源 |
|
| 用户进行普通聊天 | 没有继续工作或历史依据需求 | 不强制每轮查询或注入完整图谱;需要核对原话时才进一步读来源 |
|
||||||
| 监督者开启且内置能力已分配,没有自动计划 | 查询已有结果 | 可以读取;查询不触发模型、回顾或业务写入 |
|
| 监督者开启且内置能力已分配,没有自动计划 | 查询已有结果 | 可以读取;查询不触发模型、回顾或业务写入 |
|
||||||
| 监督者关闭,旧会话仍缓存工具 | 列出工具或直接调用 | 不暴露工具,调用被拒绝;已有数据保留 |
|
| 监督者关闭,旧会话仍缓存工具 | 列出工具或直接调用 | 不暴露工具,调用被拒绝;已有数据保留 |
|
||||||
|
| 监督者应用关闭 | 打开输入区对话设置 | 完全不显示“使用故事图谱”开关 |
|
||||||
|
| 监督者应用开启,会话未保存过该选项 | 打开输入区对话设置 | 知识库上方显示默认开启的共享 Switch |
|
||||||
|
| 当前会话关闭“使用故事图谱” | 本地或远程 Agent 列工具或使用旧能力会话调用 | 不暴露三个图谱工具并拒绝执行,其他会话仍按自己的设置运行 |
|
||||||
|
| 开关已保存 | 切换会话、重启或关闭后重开监督者应用 | 恢复该会话的选择;开关不发起回顾或自动注入上下文 |
|
||||||
|
| 保存失败 | 用户切换开关 | 保留最后确认值并显示错误,可重试 |
|
||||||
| 相同范围与证据分别通过本地和远程 Runtime 查询 | 翻页、读取版本或关闭监督者 | 返回相同业务语义与引用,远程实际桥接须通过验收 |
|
| 相同范围与证据分别通过本地和远程 Runtime 查询 | 翻页、读取版本或关闭监督者 | 返回相同业务语义与引用,远程实际桥接须通过验收 |
|
||||||
|
|
||||||
### US-S31 保留偏好并复用跨范围事实
|
### US-S31 保留偏好并复用跨范围事实
|
||||||
@@ -629,6 +634,19 @@
|
|||||||
| 仅部分片段成功或原来源已改版 | 再次整理 | 识别未覆盖或变更部分,不把旧版本覆盖算作新版本完成 |
|
| 仅部分片段成功或原来源已改版 | 再次整理 | 识别未覆盖或变更部分,不把旧版本覆盖算作新版本完成 |
|
||||||
| Global 结果含多个项目的归纳 | 从单项目查询 | 不把跨项目摘要伪装成项目结论,复用事实仍保留来源归属 |
|
| Global 结果含多个项目的归纳 | 从单项目查询 | 不把跨项目摘要伪装成项目结论,复用事实仍保留来源归属 |
|
||||||
|
|
||||||
|
### US-S34 独立选择监督者模型
|
||||||
|
|
||||||
|
对应 FR-S13,生效规则见[监督者模型选择](./logic-design.md#监督者模型选择)。
|
||||||
|
|
||||||
|
| Given | When | Then |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 已保存多个文本模型连接 | 在监督者“设置 → 模型”选择连接并保存 | 回顾和建议使用所选连接,无需重新输入凭据 |
|
||||||
|
| 尚未配置监督者模型,或选择“跟随应用默认” | 开始执行 | 使用应用默认文本模型 |
|
||||||
|
| 回顾正在提取或汇总 | 修改模型选择或原连接配置 | 此次回顾后续批次、故事和经验阶段保持原模型 |
|
||||||
|
| 回顾已暂停或失败 | 修改模型后继续 | 使用当前选择继续已保存进度 |
|
||||||
|
| 已选连接被删除或变成图片模型 | 打开设置并尝试执行 | 显示连接不可用,执行报错,用户可改选;不自动回退 |
|
||||||
|
| 模型选择保存失败 | 留在设置页 | 保留选择草稿及重试入口 |
|
||||||
|
|
||||||
## 验收优先级与依赖
|
## 验收优先级与依赖
|
||||||
|
|
||||||
首个可用版本应先打通以下路径:
|
首个可用版本应先打通以下路径:
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ OpenCode 与 DeepSeek Harness 不使用该值。
|
|||||||
|
|
||||||
## 会话与项目 Runtime 选择
|
## 会话与项目 Runtime 选择
|
||||||
|
|
||||||
|
监督者在自身“设置 → 模型”中引用已保存的文本模型连接,不复制连接或凭据;默认跟随与执行生效规则见[监督者模型选择](../conversation-supervision/logic-design.md#监督者模型选择)。
|
||||||
|
|
||||||
项目设置分“执行方式”和“模型”两个字段,输入框使用两栏选择器,全局默认模型仍在系统
|
项目设置分“执行方式”和“模型”两个字段,输入框使用两栏选择器,全局默认模型仍在系统
|
||||||
设置中按 Runtime 页签配置。分层规则、选择器状态、键盘行为和失效提示由
|
设置中按 Runtime 页签配置。分层规则、选择器状态、键盘行为和失效提示由
|
||||||
[执行方式与模型选择设计](./runtime-selection-design.md#3-各入口行为已实现)唯一维护。
|
[执行方式与模型选择设计](./runtime-selection-design.md#3-各入口行为已实现)唯一维护。
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ Agent `0.11.23` 提供,显式请求期限与取消仍有效。当前问答源
|
|||||||
|
|
||||||
## 跨会话进程复用(未发布)
|
## 跨会话进程复用(未发布)
|
||||||
|
|
||||||
2026-09-28 Story Graph 补充:远程 OpenCode/Continue 的 Ask 和 Execute
|
2026-09-28 Story Graph 接线、2026-10-03 会话开关补充:远程 OpenCode/Continue 的 Ask 和 Execute
|
||||||
通过现有受管 HTTP MCP、工具二进制通道读取桌面图谱。Main 固定项目范围并复核监督者
|
通过现有受管 HTTP MCP、工具二进制通道读取桌面图谱。Main 固定项目和会话,并复核监督者
|
||||||
开关及 Runtime 分配;图谱读取不复制数据库到 Host,也不调用模型。三个只读工具、
|
应用开关、会话“使用故事图谱”开关及 Runtime 分配;关闭会话开关后已有远程绑定也拒绝读取。图谱读取不复制数据库到 Host,也不调用模型。三个只读工具、
|
||||||
分页和版本限制由[专项设计](../conversation-supervision/story-graph-mcp-design.md)定义,
|
分页和版本限制由[专项设计](../conversation-supervision/story-graph-mcp-design.md)定义,
|
||||||
源码 Host 探针及协议测试见[验证记录](../conversation-supervision/progress.md#2026-09-28-story-graph-只读工具)。
|
源码 Host 探针及协议测试见[验证记录](../conversation-supervision/progress.md#2026-09-28-story-graph-只读工具)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// Deterministic SSH validation. Desktop owns SQLite; only tool messages cross SSH.
|
// Deterministic SSH validation. Desktop owns SQLite; only tool messages cross SSH.
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes, randomUUID } from 'node:crypto'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import { mkdtemp, rm } from 'node:fs/promises'
|
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||||
|
import { parseEnv } from 'node:util'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { createInterface } from 'node:readline'
|
import { createInterface } from 'node:readline'
|
||||||
@@ -26,12 +27,19 @@ const emit = (value: object) => process.stdout.write(`${JSON.stringify(value)}\n
|
|||||||
|
|
||||||
async function hostProbe() {
|
async function hostProbe() {
|
||||||
const [, , , opencode, continueEntry] = process.argv
|
const [, , , opencode, continueEntry] = process.argv
|
||||||
|
const live = process.argv[5] === '--live'
|
||||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-graph-'))
|
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-graph-'))
|
||||||
const adapter = new AgentImageToolMcp({ channelId: 'graph-probe', channelEpoch: '1', storyGraph: true }, async payload => { emit({ frame: Buffer.from(payload).toString('base64') }) })
|
const adapter = new AgentImageToolMcp({ channelId: 'graph-probe', channelEpoch: '1', storyGraph: true }, async payload => { emit({ frame: Buffer.from(payload).toString('base64') }) })
|
||||||
let disabled!: () => void
|
let disabled!: () => void
|
||||||
const disabling = new Promise<void>(resolve => { disabled = resolve })
|
const disabling = new Promise<void>(resolve => { disabled = resolve })
|
||||||
const lines = createInterface({ input: process.stdin })
|
const lines = createInterface({ input: process.stdin })
|
||||||
lines.on('line', line => { const value = JSON.parse(line); if (value.frame) adapter.onReply(Buffer.from(value.frame, 'base64')); if (value.disabled) disabled() })
|
const pendingInference = new Map<number, (response: { status: number; body: string }) => void>()
|
||||||
|
lines.on('line', line => {
|
||||||
|
const value = JSON.parse(line)
|
||||||
|
if (value.frame) adapter.onReply(Buffer.from(value.frame, 'base64'))
|
||||||
|
if (value.disabled) disabled()
|
||||||
|
if (value.inferenceReply) { pendingInference.get(value.id)?.(value.inferenceReply); pendingInference.delete(value.id) }
|
||||||
|
})
|
||||||
const client = new Client({ name: 'story-graph-host-probe', version: '1' })
|
const client = new Client({ name: 'story-graph-host-probe', version: '1' })
|
||||||
let inferenceRequests = 0
|
let inferenceRequests = 0
|
||||||
let backgroundRequests = 0
|
let backgroundRequests = 0
|
||||||
@@ -44,6 +52,13 @@ async function hostProbe() {
|
|||||||
const tool = tools?.find(tool => tool.function.name.endsWith('story_graph_search'))
|
const tool = tools?.find(tool => tool.function.name.endsWith('story_graph_search'))
|
||||||
if (tools?.length) assert.ok(tool, 'Runtime must expose Story Graph in Ask and Execute')
|
if (tools?.length) assert.ok(tool, 'Runtime must expose Story Graph in Ask and Execute')
|
||||||
else backgroundRequests++
|
else backgroundRequests++
|
||||||
|
if (live) {
|
||||||
|
const response = new Promise<{ status: number; body: string }>(resolve => pendingInference.set(inferenceRequests, resolve))
|
||||||
|
emit({ inference: { ...body, tools: tools?.filter(tool => tool.function.name.endsWith('story_graph_search')) }, id: inferenceRequests })
|
||||||
|
const reply = await response
|
||||||
|
res.writeHead(reply.status, { 'content-type': 'text/event-stream' }).end(reply.body)
|
||||||
|
return
|
||||||
|
}
|
||||||
const result = body.messages.findLast((message: { role: string }) => message.role === 'tool')
|
const result = body.messages.findLast((message: { role: string }) => message.role === 'tool')
|
||||||
if (result) assert.match(JSON.stringify(result), /Decision B/)
|
if (result) assert.match(JSON.stringify(result), /Decision B/)
|
||||||
const delta = !tool ? { role: 'assistant', content: 'Graph probe title' } : result ? { role: 'assistant', content: 'GRAPH_RUNTIME_OK' } : { role: 'assistant', tool_calls: [{ index: 0, id: 'graph-call', type: 'function', function: {
|
const delta = !tool ? { role: 'assistant', content: 'Graph probe title' } : result ? { role: 'assistant', content: 'GRAPH_RUNTIME_OK' } : { role: 'assistant', tool_calls: [{ index: 0, id: 'graph-call', type: 'function', function: {
|
||||||
@@ -97,11 +112,17 @@ async function hostProbe() {
|
|||||||
completePrompt: async (_operation, _status, _response, commit) => { commit(); done() } })
|
completePrompt: async (_operation, _status, _response, commit) => { commit(); done() } })
|
||||||
try {
|
try {
|
||||||
transcript.prepare({ bindingId: 'graph', controllerId: 'probe', operationId: workMode, requestId: workMode, preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
|
transcript.prepare({ bindingId: 'graph', controllerId: 'probe', operationId: workMode, requestId: workMode, preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
|
||||||
await owner.start({ bindingId: 'graph', operationId: workMode, requestId: workMode, prompt: [{ type: 'text', text: 'Search the graph.' }] }, workMode)
|
await owner.start({ bindingId: 'graph', operationId: workMode, requestId: workMode, prompt: [{ type: 'text', text: 'Search the story graph for Decision B using story_graph_search, then reply GRAPH_RUNTIME_OK. Do not use other tools.' }] }, workMode)
|
||||||
await completed
|
await completed
|
||||||
const page = transcript.page({ bindingId: 'graph', operationId: workMode, controllerId: 'probe', afterSequence: '0', limit: 128 })
|
const page = transcript.page({ bindingId: 'graph', operationId: workMode, controllerId: 'probe', afterSequence: '0', limit: 128 })
|
||||||
assert.equal(page.state, 'completed'); assert.match(JSON.stringify(page), /GRAPH_RUNTIME_OK/)
|
assert.equal(page.state, 'completed')
|
||||||
|
const answer = page.events.map(event => {
|
||||||
|
const update = (event.payload as { update?: { sessionUpdate: string; content?: { text?: string } } }).update
|
||||||
|
return update?.sessionUpdate === 'agent_message_chunk' ? update.content?.text ?? '' : ''
|
||||||
|
}).join('')
|
||||||
|
assert.ok(answer.includes('GRAPH_RUNTIME_OK'), 'Runtime must return the completion marker')
|
||||||
} finally { owner.close(); child.kill(); await exited; transcript.close() }
|
} finally { owner.close(); child.kill(); await exited; transcript.close() }
|
||||||
|
if (live) continue
|
||||||
const continueAdapter = new ContinueHostAdapter({ binaryPath: continueEntry!, configPath: '', workspace: root, cacheRoot: join(root, 'continue'), mode: 'chat',
|
const continueAdapter = new ContinueHostAdapter({ binaryPath: continueEntry!, configPath: '', workspace: root, cacheRoot: join(root, 'continue'), mode: 'chat',
|
||||||
modelProfile: { id: 'probe', name: 'Probe', modelName: 'graph-probe', baseUrl: `${origin}/v1`, protocol: 'openai-chat-completions', authentication: 'none' },
|
modelProfile: { id: 'probe', name: 'Probe', modelName: 'graph-probe', baseUrl: `${origin}/v1`, protocol: 'openai-chat-completions', authentication: 'none' },
|
||||||
launchHost: (entry, args, options) => {
|
launchHost: (entry, args, options) => {
|
||||||
@@ -120,27 +141,34 @@ async function hostProbe() {
|
|||||||
assert.deepEqual((await client.listTools()).tools, [])
|
assert.deepEqual((await client.listTools()).tools, [])
|
||||||
assert.equal((await client.callTool({ name: 'story_graph_search', arguments: { query: 'Decision' } })).isError, true)
|
assert.equal((await client.callTool({ name: 'story_graph_search', arguments: { query: 'Decision' } })).isError, true)
|
||||||
assert.deepEqual(failures, [])
|
assert.deepEqual(failures, [])
|
||||||
emit({ passed: true, inferenceRequests, backgroundRequests, paidCalls: 0, runtimes: ['OpenCode Ask', 'OpenCode Execute', 'Continue Ask', 'Continue Execute'] })
|
emit({ passed: true, inferenceRequests, backgroundRequests, paidCalls: live ? inferenceRequests : 0,
|
||||||
|
runtimes: live ? ['OpenCode Ask', 'OpenCode Execute'] : ['OpenCode Ask', 'OpenCode Execute', 'Continue Ask', 'Continue Execute'] })
|
||||||
} finally { lines.close(); await client.close(); adapter.close(); inference.closeAllConnections(); inference.close(); await rm(root, { recursive: true, force: true }) }
|
} finally { lines.close(); await client.close(); adapter.close(); inference.closeAllConnections(); inference.close(); await rm(root, { recursive: true, force: true }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function desktopProbe() {
|
async function desktopProbe() {
|
||||||
const [, , host, remoteScript, opencode, continueEntry] = process.argv
|
const [, , host, remoteScript, opencode, continueEntry] = process.argv
|
||||||
assert.ok(host && remoteScript && opencode && continueEntry, 'Expected host, remote bundle, OpenCode and Continue paths')
|
assert.ok(host && remoteScript && opencode && continueEntry, 'Expected host, remote bundle, OpenCode and Continue paths')
|
||||||
|
const liveConfig = process.argv[6] ? parseEnv(await readFile(process.argv[6], 'utf8')) : undefined
|
||||||
|
if (liveConfig) assert.ok(liveConfig.DEEPSEEK_API_KEY && liveConfig.DEEPSEEK_BASE_URL && liveConfig.DEEPSEEK_MODEL, 'Live model configuration unavailable')
|
||||||
|
let providerCalls = 0
|
||||||
const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd())
|
const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd())
|
||||||
const projectId = db.listProjects()[0]!.id
|
const projectId = db.listProjects()[0]!.id
|
||||||
|
const conversationId = randomUUID()
|
||||||
|
const otherConversationId = randomUUID()
|
||||||
|
db.saveLocalConversations([conversationId, otherConversationId].map(id => ({ header: { id, projectId, title: 'Graph probe', updatedAt: Date.now() }, messages: [] })))
|
||||||
db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } },
|
db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } },
|
||||||
evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'probe', title: 'Decision evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A explicitly revised to Decision B; implementation remains pending.' }],
|
evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'probe', title: 'Decision evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A explicitly revised to Decision B; implementation remains pending.' }],
|
||||||
output: { summary: 'Decision B', changeDigest: 'Revision A to B', openItems: ['Implementation pending'], events: [], entityChanges: [], relations: [],
|
output: { summary: 'Decision B', changeDigest: 'Revision A to B', openItems: ['Implementation pending'], events: [], entityChanges: [], relations: [],
|
||||||
entities: [{ id: 'decision', label: 'Decision B', description: 'Explicit revision with evidence', sourceReferenceIds: ['quote'] }] } })
|
entities: [{ id: 'decision', label: 'Decision B', description: 'Explicit revision with evidence', sourceReferenceIds: ['quote'] }] } })
|
||||||
let enabled = true, reads = 0, passed = false
|
let reads = 0, passed = false
|
||||||
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available: async () => enabled,
|
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available: async binding => db.isConversationStoryGraphEnabled(binding.conversationId!),
|
||||||
read: (...args) => { assert.ok(enabled); reads++; return db.readStoryGraph(...args) } } })
|
read: (...args) => { assert.ok(db.isConversationStoryGraphEnabled(conversationId)); reads++; return db.readStoryGraph(...args) } } })
|
||||||
const signal = AbortSignal.timeout(150_000)
|
const signal = AbortSignal.timeout(150_000)
|
||||||
const token = gateway.grant('probe', [], signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: 'opencode' })!
|
const token = gateway.grant('probe', [], signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, conversationId, runtimeTarget: 'opencode' })!
|
||||||
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
|
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`
|
||||||
const child = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5', host!,
|
const child = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5', host!,
|
||||||
`TMPDIR=/root/tmp ELECTRON_RUN_AS_NODE=1 /opt/GoodBuddy/goodbuddy ${quote(remoteScript!)} --host ${quote(opencode!)} ${quote(continueEntry!)}`], { stdio: ['pipe', 'pipe', 'inherit'], signal })
|
`TMPDIR=/root/tmp ELECTRON_RUN_AS_NODE=1 /opt/GoodBuddy/goodbuddy ${quote(remoteScript!)} --host ${quote(opencode!)} ${quote(continueEntry!)} ${liveConfig ? '--live' : ''}`], { stdio: ['pipe', 'pipe', 'inherit'], signal })
|
||||||
const exited = new Promise<number | null>((resolve, reject) => { child.once('close', resolve); child.once('error', reject) })
|
const exited = new Promise<number | null>((resolve, reject) => { child.once('close', resolve); child.once('error', reject) })
|
||||||
const queue: RuntimeProtocolBinaryFrame[] = []
|
const queue: RuntimeProtocolBinaryFrame[] = []
|
||||||
let receive: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined
|
let receive: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined
|
||||||
@@ -153,14 +181,29 @@ async function desktopProbe() {
|
|||||||
const lines = createInterface({ input: child.stdout })
|
const lines = createInterface({ input: child.stdout })
|
||||||
lines.on('line', line => {
|
lines.on('line', line => {
|
||||||
const value = JSON.parse(line)
|
const value = JSON.parse(line)
|
||||||
if (value.frame) {
|
if (value.inference) {
|
||||||
|
void (async () => {
|
||||||
|
assert.ok(liveConfig && ++providerCalls <= 8, 'Live request budget exhausted')
|
||||||
|
const base = liveConfig.DEEPSEEK_BASE_URL!.replace(/\/+$/, '')
|
||||||
|
const response = await fetch(base.endsWith('/chat/completions') ? base : `${base}/chat/completions`, {
|
||||||
|
method: 'POST', headers: { Authorization: `Bearer ${liveConfig.DEEPSEEK_API_KEY}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...value.inference, model: liveConfig.DEEPSEEK_MODEL, max_tokens: 512 }), signal
|
||||||
|
})
|
||||||
|
assert.ok(response.ok, `Provider HTTP ${response.status}`)
|
||||||
|
child.stdin.write(`${JSON.stringify({ id: value.id, inferenceReply: { status: response.status, body: await response.text() } })}\n`)
|
||||||
|
})().catch(() => { child.stdin.write(`${JSON.stringify({ id: value.id, inferenceReply: { status: 502, body: 'Live provider request failed' } })}\n`) })
|
||||||
|
} else if (value.frame) {
|
||||||
const frame = { payload: Buffer.from(value.frame, 'base64'), sequence: '1', consume: async () => {} }
|
const frame = { payload: Buffer.from(value.frame, 'base64'), sequence: '1', consume: async () => {} }
|
||||||
if (receive) { const resolve = receive; receive = undefined; resolve(frame) } else queue.push(frame)
|
if (receive) { const resolve = receive; receive = undefined; resolve(frame) } else queue.push(frame)
|
||||||
} else if (value.disable) { enabled = false; child.stdin.write('{"disabled":true}\n') }
|
} else if (value.disable) {
|
||||||
else if (value.passed) { passed = true; emit({ ...value, desktopReads: reads, desktopOwnsDatabase: true }) }
|
db.setConversationStoryGraphEnabled(conversationId, false)
|
||||||
|
assert.ok(db.isConversationStoryGraphEnabled(otherConversationId))
|
||||||
|
child.stdin.write('{"disabled":true}\n')
|
||||||
|
}
|
||||||
|
else if (value.passed) { passed = true; emit({ ...value, providerCalls, desktopReads: reads, desktopOwnsDatabase: true }) }
|
||||||
})
|
})
|
||||||
try { assert.equal(await exited, 0); assert.ok(passed) }
|
try { assert.equal(await exited, 0); assert.ok(passed) }
|
||||||
finally { lines.close(); main.close(); child.kill(); await gateway.dispose(); db.close() }
|
finally { emit({ providerCalls }); lines.close(); main.close(); child.kill(); await gateway.dispose(); db.close() }
|
||||||
}
|
}
|
||||||
|
|
||||||
void (process.argv[2] === '--host' ? hostProbe() : desktopProbe()).catch(error => { console.error(error); process.exitCode = 1 })
|
void (process.argv[2] === '--host' ? hostProbe() : desktopProbe()).catch(error => { console.error(error); process.exitCode = 1 })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
|||||||
import { afterEach, expect, it, vi } from 'vitest'
|
import { afterEach, expect, it, vi } from 'vitest'
|
||||||
import { AssistantDatabase } from '../main/assistant/assistant-database'
|
import { AssistantDatabase } from '../main/assistant/assistant-database'
|
||||||
import type { KnowledgeService } from '../main/knowledge/knowledge-service'
|
import type { KnowledgeService } from '../main/knowledge/knowledge-service'
|
||||||
import { KnowledgeMcpGateway } from '../main/agent/knowledge-mcp-gateway'
|
import { KnowledgeMcpGateway, type StoryGraphBinding } from '../main/agent/knowledge-mcp-gateway'
|
||||||
import { ModelToolProvider } from '../main/agent/model-tool-provider'
|
import { ModelToolProvider } from '../main/agent/model-tool-provider'
|
||||||
import { AgentImageToolMcp } from './image-tool-mcp'
|
import { AgentImageToolMcp } from './image-tool-mcp'
|
||||||
import { MainImageToolSession } from '../main/remote-agent/main-image-tool-session'
|
import { MainImageToolSession } from '../main/remote-agent/main-image-tool-session'
|
||||||
@@ -19,18 +19,20 @@ function fixture() {
|
|||||||
const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd())
|
const db = new AssistantDatabase(':memory:'); db.initialize(process.cwd())
|
||||||
cleanups.push(() => db.close())
|
cleanups.push(() => db.close())
|
||||||
const projectId = db.listProjects()[0]!.id
|
const projectId = db.listProjects()[0]!.id
|
||||||
|
const conversationId = randomUUID()
|
||||||
|
db.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Graph', updatedAt: Date.now() }, messages: [] }])
|
||||||
db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } },
|
db.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-30T00:00:00Z' } },
|
||||||
evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'original', title: 'Evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A is revised to B, C remains only an option.' }],
|
evidence: [{ id: 'quote', sourceType: 'conversation', sourceId: 'original', title: 'Evidence', occurredAt: '2026-09-01T00:00:00Z', content: 'Decision A is revised to B, C remains only an option.' }],
|
||||||
output: { summary: 'Decision history', changeDigest: '', openItems: ['Not yet executed'], entities: [{ id: 'decision', label: 'Decision', description: 'Decision A revised to B', sourceReferenceIds: ['quote'] }],
|
output: { summary: 'Decision history', changeDigest: '', openItems: ['Not yet executed'], entities: [{ id: 'decision', label: 'Decision', description: 'Decision A revised to B', sourceReferenceIds: ['quote'] }],
|
||||||
events: [], relations: [], entityChanges: [] } })
|
events: [], relations: [], entityChanges: [] } })
|
||||||
let enabled = true, assigned = true
|
let enabled = true, assigned = true
|
||||||
const read = vi.fn(db.readStoryGraph.bind(db))
|
const read = vi.fn(db.readStoryGraph.bind(db))
|
||||||
const available = vi.fn(async () => enabled && assigned)
|
const available = vi.fn(async (binding: StoryGraphBinding) => enabled && assigned && db.isConversationStoryGraphEnabled(binding.conversationId!))
|
||||||
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available, read } })
|
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: { available, read } })
|
||||||
cleanups.push(() => gateway.dispose())
|
cleanups.push(() => gateway.dispose())
|
||||||
const wait = new AbortController()
|
const wait = new AbortController()
|
||||||
const token = gateway.grant('graph', [], wait.signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: 'model' })!
|
const token = gateway.grant('graph', [], wait.signal, 'none', undefined, undefined, undefined, undefined, undefined, { projectId, conversationId, runtimeTarget: 'model' })!
|
||||||
return { db, projectId, gateway, token, wait, read, available,
|
return { db, projectId, conversationId, gateway, token, wait, read, available,
|
||||||
disable: () => { enabled = false }, unassign: () => { assigned = false } }
|
disable: () => { enabled = false }, unassign: () => { assigned = false } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ async function client(url: string, token?: string) {
|
|||||||
return connection
|
return connection
|
||||||
}
|
}
|
||||||
|
|
||||||
it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and revokes discovery and old calls when disabled', async transport => {
|
it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and revokes discovery and old calls when conversation is disabled', async transport => {
|
||||||
const f = fixture()
|
const f = fixture()
|
||||||
let connection: Client
|
let connection: Client
|
||||||
if (transport === 'local') {
|
if (transport === 'local') {
|
||||||
@@ -84,11 +86,15 @@ it.each(['local', 'remote'] as const)('%s HTTP MCP reads SQLite through Main and
|
|||||||
expect(unsupported.isError).toBe(true)
|
expect(unsupported.isError).toBe(true)
|
||||||
expect(JSON.stringify(unsupported)).toContain('unsupported_mode')
|
expect(JSON.stringify(unsupported)).toContain('unsupported_mode')
|
||||||
const before = f.read.mock.calls.length
|
const before = f.read.mock.calls.length
|
||||||
f.disable()
|
f.db.setConversationStoryGraphEnabled(f.conversationId, false)
|
||||||
expect((await connection.listTools()).tools).toEqual([])
|
expect((await connection.listTools()).tools).toEqual([])
|
||||||
const blocked = await connection.callTool({ name: 'story_graph_search', arguments: { query: 'Decision', cursor: result.page.next_cursor, page_size: 1 } })
|
const blocked = await connection.callTool({ name: 'story_graph_search', arguments: { query: 'Decision', cursor: result.page.next_cursor, page_size: 1 } })
|
||||||
expect(blocked.isError).toBe(true)
|
expect(blocked.isError).toBe(true)
|
||||||
expect(f.read).toHaveBeenCalledTimes(before)
|
expect(f.read).toHaveBeenCalledTimes(before)
|
||||||
|
f.db.setConversationStoryGraphEnabled(f.conversationId, true)
|
||||||
|
expect((await connection.listTools()).tools.map(tool => tool.name)).toEqual(storyGraphToolNames)
|
||||||
|
f.disable()
|
||||||
|
expect((await connection.listTools()).tools).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and runtime assignment gate', async workMode => {
|
it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and runtime assignment gate', async workMode => {
|
||||||
@@ -99,6 +105,10 @@ it.each(['ask', 'execute'] as const)('direct Model %s uses the same reader and r
|
|||||||
expect((await provider.listTools(context, f.wait.signal)).filter(tool => tool.name.startsWith('story_graph_')).map(tool => tool.name)).toEqual(storyGraphToolNames)
|
expect((await provider.listTools(context, f.wait.signal)).filter(tool => tool.name.startsWith('story_graph_')).map(tool => tool.name)).toEqual(storyGraphToolNames)
|
||||||
const result = await provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)
|
const result = await provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)
|
||||||
expect(JSON.stringify(result)).toContain(f.projectId)
|
expect(JSON.stringify(result)).toContain(f.projectId)
|
||||||
|
f.db.setConversationStoryGraphEnabled(f.conversationId, false)
|
||||||
|
expect((await provider.listTools(context, f.wait.signal)).some(tool => tool.name.startsWith('story_graph_'))).toBe(false)
|
||||||
|
await expect(provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)).rejects.toThrow('story_graph_unavailable')
|
||||||
|
f.db.setConversationStoryGraphEnabled(f.conversationId, true)
|
||||||
f.unassign()
|
f.unassign()
|
||||||
expect((await provider.listTools(context, f.wait.signal)).some(tool => tool.name.startsWith('story_graph_'))).toBe(false)
|
expect((await provider.listTools(context, f.wait.signal)).some(tool => tool.name.startsWith('story_graph_'))).toBe(false)
|
||||||
await expect(provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)).rejects.toThrow('story_graph_unavailable')
|
await expect(provider.callTool('story_graph_search', { query: 'Decision' }, f.wait.signal, context)).rejects.toThrow('story_graph_unavailable')
|
||||||
@@ -110,6 +120,6 @@ it('rejects disabled in-flight delivery and cancellation before the synchronous
|
|||||||
await expect(f.gateway.callStoryGraphTool(f.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow()
|
await expect(f.gateway.callStoryGraphTool(f.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow()
|
||||||
expect(f.read).not.toHaveBeenCalled()
|
expect(f.read).not.toHaveBeenCalled()
|
||||||
const other = fixture()
|
const other = fixture()
|
||||||
other.read.mockImplementationOnce((...args) => { const result = other.db.readStoryGraph(...args); other.disable(); return result })
|
other.read.mockImplementationOnce((...args) => { const result = other.db.readStoryGraph(...args); other.db.setConversationStoryGraphEnabled(other.conversationId, false); return result })
|
||||||
await expect(other.gateway.callStoryGraphTool(other.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow('story_graph_unavailable')
|
await expect(other.gateway.callStoryGraphTool(other.token, 'story_graph_search', { query: 'Decision' })).rejects.toThrow('story_graph_unavailable')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,12 +109,14 @@ it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and E
|
|||||||
const db = new AssistantDatabase(':memory:'); db.initialize(root)
|
const db = new AssistantDatabase(':memory:'); db.initialize(root)
|
||||||
let enabled = true
|
let enabled = true
|
||||||
const projectId = db.listProjects()[0]!.id
|
const projectId = db.listProjects()[0]!.id
|
||||||
|
const conversationId = crypto.randomUUID()
|
||||||
|
db.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Graph', updatedAt: Date.now() }, messages: [] }])
|
||||||
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: {
|
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: {
|
||||||
available: async () => enabled, read: db.readStoryGraph.bind(db)
|
available: async binding => db.isConversationStoryGraphEnabled(binding.conversationId!), read: db.readStoryGraph.bind(db)
|
||||||
} })
|
} })
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
const token = gateway.grant('harness-graph', [], signal, 'none', undefined, undefined, undefined, undefined, undefined,
|
const token = gateway.grant('harness-graph', [], signal, 'none', undefined, undefined, undefined, undefined, undefined,
|
||||||
{ projectId, runtimeTarget: 'deepseek-harness' })!
|
{ projectId, conversationId, runtimeTarget: 'deepseek-harness' })!
|
||||||
const inProcess = createInProcessLaunch(root, { stream(options) {
|
const inProcess = createInProcessLaunch(root, { stream(options) {
|
||||||
const prompt = latestUserText(options)
|
const prompt = latestUserText(options)
|
||||||
const tool = options.tools?.find(tool => tool.name === 'story_graph_search')
|
const tool = options.tools?.find(tool => tool.name === 'story_graph_search')
|
||||||
@@ -131,7 +133,8 @@ it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and E
|
|||||||
try {
|
try {
|
||||||
for (const mode of ['ask', 'execute', 'disabled'] as const) {
|
for (const mode of ['ask', 'execute', 'disabled'] as const) {
|
||||||
enabled = mode !== 'disabled'
|
enabled = mode !== 'disabled'
|
||||||
const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId: 'graph', prompt: mode,
|
db.setConversationStoryGraphEnabled(conversationId, enabled)
|
||||||
|
const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId, prompt: mode,
|
||||||
workMode: mode === 'execute' ? 'execute' : 'ask', knowledgeCapabilityToken: token }, signal))
|
workMode: mode === 'execute' ? 'execute' : 'ask', knowledgeCapabilityToken: token }, signal))
|
||||||
expect(events.at(-1)?.type).toBe('done')
|
expect(events.at(-1)?.type).toBe('done')
|
||||||
expect(JSON.stringify(events)).toContain(enabled ? 'GRAPH_READ' : 'DISABLED')
|
expect(JSON.stringify(events)).toContain(enabled ? 'GRAPH_READ' : 'DISABLED')
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } f
|
|||||||
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
|
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
|
||||||
import type { RuntimeTarget } from '../../shared/capability-contracts'
|
import type { RuntimeTarget } from '../../shared/capability-contracts'
|
||||||
|
|
||||||
export type StoryGraphBinding = { projectId?: string; runtimeTarget: RuntimeTarget }
|
export type StoryGraphBinding = { projectId?: string; conversationId?: string; runtimeTarget: RuntimeTarget }
|
||||||
export type StoryGraphRemoteBinding = {
|
export type StoryGraphRemoteBinding = {
|
||||||
available(): Promise<boolean>
|
available(): Promise<boolean>
|
||||||
call(name: StoryGraphToolName, input: unknown, signal: AbortSignal): Promise<Record<string, unknown>>
|
call(name: StoryGraphToolName, input: unknown, signal: AbortSignal): Promise<Record<string, unknown>>
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
|
|||||||
const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions',
|
const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions',
|
||||||
authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' }
|
authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' }
|
||||||
const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile }
|
const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile }
|
||||||
const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], workMode: 'ask' }
|
const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], workMode: 'ask', storyGraphEnabled: true }
|
||||||
const project = { id: 'project', runtimeSelection: { provider: 'deepseek-harness', profileId: profile.id } }
|
const project = { id: 'project', runtimeSelection: { provider: 'deepseek-harness', profileId: profile.id } }
|
||||||
const application = { magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } }
|
const application = { heartbeatEnabled: false, magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } }
|
||||||
const gateway = { start: vi.fn(), dispose: vi.fn(), grant: vi.fn(() => 'builtin-token'),
|
const gateway = { start: vi.fn(), dispose: vi.fn(), grant: vi.fn(() => 'builtin-token'),
|
||||||
grantCustomMcp: vi.fn(() => 'custom-token'), getEndpoint: () => 'http://127.0.0.1:1234/mcp',
|
grantCustomMcp: vi.fn(() => 'custom-token'), getEndpoint: () => 'http://127.0.0.1:1234/mcp',
|
||||||
getAvailableToolNames: vi.fn(() => ['knowledge_search', 'obsidian_read_note']) }
|
getAvailableToolNames: vi.fn(() => ['knowledge_search', 'obsidian_read_note']) }
|
||||||
@@ -30,11 +30,11 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
|
|||||||
const stop = vi.spyOn(NativeDshWebClientService.prototype, 'stop').mockImplementation(async id => { handles.delete(id) })
|
const stop = vi.spyOn(NativeDshWebClientService.prototype, 'stop').mockImplementation(async id => { handles.delete(id) })
|
||||||
const openExternal = vi.fn(async () => {})
|
const openExternal = vi.fn(async () => {})
|
||||||
const coordinator = new NativeClientCoordinator({
|
const coordinator = new NativeClientCoordinator({
|
||||||
database: { getConversation: () => conversation, getProject: () => project },
|
database: { getConversation: (id: string) => ({ ...conversation, id }), getProject: () => project },
|
||||||
settingsStore: { getResolvedSettings: async () => settings },
|
settingsStore: { getResolvedSettings: async () => settings },
|
||||||
applicationSettingsStore: { get: async () => application },
|
applicationSettingsStore: { get: async () => application },
|
||||||
capabilities: { getRuntimeSkillContext: async () => ({ packages: [{ directory: '/skills/test', id: 'test', digest: 'one' }] }),
|
capabilities: { getRuntimeSkillContext: async () => ({ packages: [{ directory: '/skills/test', id: 'test', digest: 'one' }] }),
|
||||||
getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config'],
|
getResolvedMcpServers: async () => [{ id: 'custom' }], getEnabledBuiltinMcpServerIds: async () => ['knowledge-base', 'obsidian', 'goodbuddy-config', 'story-graph'],
|
||||||
getObsidianSettings: async () => ({}) },
|
getObsidianSettings: async () => ({}) },
|
||||||
executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) },
|
executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) },
|
||||||
terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady },
|
terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady },
|
||||||
@@ -45,6 +45,17 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('native client coordinator', () => {
|
describe('native client coordinator', () => {
|
||||||
|
it('binds story graph to its conversation and does not reuse another conversation or disabled configuration', async () => {
|
||||||
|
const { coordinator, conversation, application, gateway } = fixture()
|
||||||
|
application.heartbeatEnabled = true
|
||||||
|
await coordinator.open(1, 'first')
|
||||||
|
expect(gateway.grant.mock.calls.at(-1)?.at(-1)).toEqual({ projectId: 'project', conversationId: 'first', runtimeTarget: 'deepseek-harness' })
|
||||||
|
expect(await coordinator.get(1, 'second')).toBeNull()
|
||||||
|
conversation.storyGraphEnabled = false
|
||||||
|
expect(await coordinator.get(1, 'first')).toBeNull()
|
||||||
|
await coordinator.open(1, 'first')
|
||||||
|
expect(gateway.grant.mock.calls.at(-1)?.at(-1)).toBeUndefined()
|
||||||
|
})
|
||||||
it('waits for the startup tool environment before launching a native client', async () => {
|
it('waits for the startup tool environment before launching a native client', async () => {
|
||||||
let ready!: () => void
|
let ready!: () => void
|
||||||
const { coordinator, start } = fixture(() => new Promise<void>(resolve => { ready = resolve }))
|
const { coordinator, start } = fixture(() => new Promise<void>(resolve => { ready = resolve }))
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export class NativeClientCoordinator {
|
|||||||
this.options.applicationSettingsStore.get(),
|
this.options.applicationSettingsStore.get(),
|
||||||
this.options.capabilities.getObsidianSettings()
|
this.options.capabilities.getObsidianSettings()
|
||||||
])
|
])
|
||||||
const key = createHash('sha256').update(JSON.stringify({ projectId: project.id, space: space.cacheIdentity,
|
const key = createHash('sha256').update(JSON.stringify({ projectId: project.id, conversationId, storyGraphEnabled: conversation.storyGraphEnabled !== false, space: space.cacheIdentity,
|
||||||
target: selected.target, profile: selected.target === 'deepseek-harness' ? selected.settings.deepseekHarnessModelProfile
|
target: selected.target, profile: selected.target === 'deepseek-harness' ? selected.settings.deepseekHarnessModelProfile
|
||||||
: selected.target === 'continue' ? selected.settings.continueModelProfile : selected.settings.opencodeModelProfile,
|
: selected.target === 'continue' ? selected.settings.continueModelProfile : selected.settings.opencodeModelProfile,
|
||||||
workMode, skills, mcpServers, builtin,
|
workMode, skills, mcpServers, builtin,
|
||||||
@@ -168,7 +168,8 @@ export class NativeClientCoordinator {
|
|||||||
builtin.includes('magic-notes') && application.magicNotesEnabled ? access : 'none',
|
builtin.includes('magic-notes') && application.magicNotesEnabled ? access : 'none',
|
||||||
builtin.includes('goodbuddy-config') ? { access, workspacePath: space.rootPath } : undefined,
|
builtin.includes('goodbuddy-config') ? { access, workspacePath: space.rootPath } : undefined,
|
||||||
undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined,
|
undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined,
|
||||||
builtin.includes('story-graph') && application.heartbeatEnabled ? { projectId: project.id, runtimeTarget: selected.target } : undefined)
|
builtin.includes('story-graph') && application.heartbeatEnabled && conversation.storyGraphEnabled !== false
|
||||||
|
? { projectId: project.id, conversationId: conversation.id, runtimeTarget: selected.target } : undefined)
|
||||||
const customToken = workMode === 'execute' ? gateway.grantCustomMcp(requestId, mcpServers, signal) : undefined
|
const customToken = workMode === 'execute' ? gateway.grantCustomMcp(requestId, mcpServers, signal) : undefined
|
||||||
const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` },
|
const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` },
|
||||||
readOnlyTools: index === 0 && workMode === 'ask' ? gateway.getAvailableToolNames(value) : [] }] : [])
|
readOnlyTools: index === 0 && workMode === 'ask' ? gateway.getAvailableToolNames(value) : [] }] : [])
|
||||||
|
|||||||
@@ -141,6 +141,23 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
await expect(store.update({ supervisionReview: { ...supervisionReview, ...patch } })).rejects.toThrow()
|
await expect(store.update({ supervisionReview: { ...supervisionReview, ...patch } })).rejects.toThrow()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
it.each([11, 12])('persists the supervisor model reference and defaults old settings to app default in version %s', async (version) => {
|
||||||
|
const { filePath, store } = await createStore()
|
||||||
|
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
|
||||||
|
delete legacy.supervisorModelProfileId
|
||||||
|
await writeFile(filePath, JSON.stringify(legacy))
|
||||||
|
expect((await store.get()).supervisorModelProfileId).toBeNull()
|
||||||
|
const profileId = '00000000-0000-4000-8000-000000000201'
|
||||||
|
await store.update({ supervisorModelProfileId: profileId })
|
||||||
|
await store.update({ checkUpdatesOnStartup: false })
|
||||||
|
expect((await createApplicationSettingsStore(filePath).get()).supervisorModelProfileId).toBe(profileId)
|
||||||
|
for (const value of ['', 'missing', 12, { apiKey: 'not-a-profile-reference' }]) {
|
||||||
|
await expect(store.update({ supervisorModelProfileId: value })).rejects.toThrow()
|
||||||
|
}
|
||||||
|
await store.update({ supervisorModelProfileId: null })
|
||||||
|
expect((await createApplicationSettingsStore(filePath).get()).supervisorModelProfileId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it.each([11, 12])('persists supervision concurrency and defaults missing values in version %s', async (version) => {
|
it.each([11, 12])('persists supervision concurrency and defaults missing values in version %s', async (version) => {
|
||||||
const { filePath, store } = await createStore()
|
const { filePath, store } = await createStore()
|
||||||
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
|
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
|
|||||||
heartbeatReportTimeoutSeconds: defaultSupervisionTimeoutSeconds,
|
heartbeatReportTimeoutSeconds: defaultSupervisionTimeoutSeconds,
|
||||||
supervisorOrganizeTimeoutSeconds: defaultSupervisionTimeoutSeconds,
|
supervisorOrganizeTimeoutSeconds: defaultSupervisionTimeoutSeconds,
|
||||||
supervisorModelConcurrency: defaultSupervisorModelConcurrency,
|
supervisorModelConcurrency: defaultSupervisorModelConcurrency,
|
||||||
|
supervisorModelProfileId: null,
|
||||||
magicNotesShowIncompleteTodoCount: true,
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
@@ -545,6 +546,7 @@ export class ApplicationSettingsStore {
|
|||||||
heartbeatReportTimeoutSeconds: stored.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
heartbeatReportTimeoutSeconds: stored.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||||
supervisorOrganizeTimeoutSeconds: stored.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
supervisorOrganizeTimeoutSeconds: stored.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||||
supervisorModelConcurrency: stored.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency,
|
supervisorModelConcurrency: stored.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency,
|
||||||
|
supervisorModelProfileId: stored.supervisorModelProfileId ?? null,
|
||||||
supervisionReview: stored.supervisionReview,
|
supervisionReview: stored.supervisionReview,
|
||||||
magicNotesShowIncompleteTodoCount:
|
magicNotesShowIncompleteTodoCount:
|
||||||
stored.magicNotesShowIncompleteTodoCount,
|
stored.magicNotesShowIncompleteTodoCount,
|
||||||
@@ -593,6 +595,7 @@ export class ApplicationSettingsStore {
|
|||||||
heartbeatReportTimeoutSeconds: next.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
heartbeatReportTimeoutSeconds: next.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||||
supervisorOrganizeTimeoutSeconds: next.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
supervisorOrganizeTimeoutSeconds: next.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||||
supervisorModelConcurrency: next.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency,
|
supervisorModelConcurrency: next.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency,
|
||||||
|
supervisorModelProfileId: next.supervisorModelProfileId ?? null,
|
||||||
supervisionReview: next.supervisionReview,
|
supervisionReview: next.supervisionReview,
|
||||||
magicNotesShowIncompleteTodoCount:
|
magicNotesShowIncompleteTodoCount:
|
||||||
next.magicNotesShowIncompleteTodoCount,
|
next.magicNotesShowIncompleteTodoCount,
|
||||||
|
|||||||
@@ -101,6 +101,27 @@ it('roundtrips canvas assets and keeps todos stable across geometry edits and co
|
|||||||
} finally { database.close() }
|
} finally { database.close() }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('persists the story graph switch per conversation without schema migration or stale autosave overrides', async () => {
|
||||||
|
const database = await createDatabase()
|
||||||
|
const header = { id: randomUUID(), title: 'Graph preference', updatedAt: 1000, knowledgeRetrievalMode: 'always' as const }
|
||||||
|
const other = { ...header, id: randomUUID() }
|
||||||
|
try {
|
||||||
|
database.saveLocalConversations([{ header, messages: [] }, { header: other, messages: [] }])
|
||||||
|
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(true)
|
||||||
|
database.setConversationStoryGraphEnabled(header.id, false)
|
||||||
|
database.saveLocalConversations([{ header, messages: [] }])
|
||||||
|
database.close()
|
||||||
|
database.initialize('C:\\Workspace')
|
||||||
|
expect(database.getConversation(header.id)).toMatchObject({ storyGraphEnabled: false, knowledgeRetrievalMode: 'always' })
|
||||||
|
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(false)
|
||||||
|
expect(database.isConversationStoryGraphEnabled(other.id)).toBe(true)
|
||||||
|
database.setConversationStoryGraphEnabled(header.id, true)
|
||||||
|
expect(database.isConversationStoryGraphEnabled(header.id)).toBe(true)
|
||||||
|
expect(database.isConversationStoryGraphEnabled(randomUUID())).toBe(false)
|
||||||
|
expect(() => database.setConversationStoryGraphEnabled(randomUUID(), false)).toThrow('对话不存在')
|
||||||
|
} finally { database.close() }
|
||||||
|
})
|
||||||
|
|
||||||
it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => {
|
it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => {
|
||||||
const database = await createDatabase()
|
const database = await createDatabase()
|
||||||
const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 }
|
const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 }
|
||||||
|
|||||||
@@ -1234,6 +1234,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({
|
|||||||
workMode: true,
|
workMode: true,
|
||||||
knowledgeLibraryIds: true,
|
knowledgeLibraryIds: true,
|
||||||
knowledgeRetrievalMode: true,
|
knowledgeRetrievalMode: true,
|
||||||
|
storyGraphEnabled: true,
|
||||||
contextMetrics: true,
|
contextMetrics: true,
|
||||||
contextCompressionState: true
|
contextCompressionState: true
|
||||||
})
|
})
|
||||||
@@ -1242,7 +1243,7 @@ function parseConversationContextState(
|
|||||||
value: string | null
|
value: string | null
|
||||||
): Pick<
|
): Pick<
|
||||||
ConversationSnapshot,
|
ConversationSnapshot,
|
||||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState'
|
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||||
> {
|
> {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return {}
|
return {}
|
||||||
@@ -1260,18 +1261,20 @@ function parseConversationContextState(
|
|||||||
function serializeConversationContextState(
|
function serializeConversationContextState(
|
||||||
conversation: Pick<
|
conversation: Pick<
|
||||||
ConversationSnapshot,
|
ConversationSnapshot,
|
||||||
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'contextMetrics' | 'contextCompressionState'
|
'workMode' | 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||||
>
|
>
|
||||||
): string | null {
|
): string | null {
|
||||||
return conversation.workMode !== undefined ||
|
return conversation.workMode !== undefined ||
|
||||||
conversation.knowledgeLibraryIds !== undefined ||
|
conversation.knowledgeLibraryIds !== undefined ||
|
||||||
conversation.knowledgeRetrievalMode !== undefined ||
|
conversation.knowledgeRetrievalMode !== undefined ||
|
||||||
|
conversation.storyGraphEnabled !== undefined ||
|
||||||
conversation.contextMetrics ||
|
conversation.contextMetrics ||
|
||||||
conversation.contextCompressionState
|
conversation.contextCompressionState
|
||||||
? JSON.stringify({
|
? JSON.stringify({
|
||||||
workMode: conversation.workMode,
|
workMode: conversation.workMode,
|
||||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||||
|
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||||
contextMetrics: conversation.contextMetrics,
|
contextMetrics: conversation.contextMetrics,
|
||||||
contextCompressionState: conversation.contextCompressionState
|
contextCompressionState: conversation.contextCompressionState
|
||||||
})
|
})
|
||||||
@@ -3194,6 +3197,22 @@ export class AssistantDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isConversationStoryGraphEnabled(conversationId: string): boolean {
|
||||||
|
const row = this.requireDatabase().prepare(
|
||||||
|
"SELECT context_state_json FROM conversations WHERE id = ? AND status = 'active'"
|
||||||
|
).get(conversationId) as { context_state_json: string | null } | undefined
|
||||||
|
return Boolean(row && parseConversationContextState(row.context_state_json).storyGraphEnabled !== false)
|
||||||
|
}
|
||||||
|
|
||||||
|
setConversationStoryGraphEnabled(conversationId: string, enabled: boolean): void {
|
||||||
|
const result = this.requireDatabase().prepare(
|
||||||
|
`UPDATE conversations
|
||||||
|
SET context_state_json = json_set(COALESCE(context_state_json, '{}'), '$.storyGraphEnabled', json(?))
|
||||||
|
WHERE id = ? AND status = 'active'`
|
||||||
|
).run(JSON.stringify(enabled), conversationId)
|
||||||
|
if (result.changes !== 1) throw new Error('对话不存在')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears references to deleted model connections in every project and
|
* Clears references to deleted model connections in every project and
|
||||||
* conversation so they fall back to the next layer instead of failing.
|
* conversation so they fall back to the next layer instead of failing.
|
||||||
@@ -3324,7 +3343,7 @@ export class AssistantDatabase {
|
|||||||
saveLocalConversations(batch: LocalConversationSaveBatch): void {
|
saveLocalConversations(batch: LocalConversationSaveBatch): void {
|
||||||
const database = this.requireDatabase()
|
const database = this.requireDatabase()
|
||||||
const findConversation = database.prepare(
|
const findConversation = database.prepare(
|
||||||
'SELECT channel FROM conversations WHERE id = ?'
|
'SELECT channel, context_state_json FROM conversations WHERE id = ?'
|
||||||
)
|
)
|
||||||
const insertConversation = database.prepare(
|
const insertConversation = database.prepare(
|
||||||
`INSERT INTO conversations
|
`INSERT INTO conversations
|
||||||
@@ -3376,7 +3395,7 @@ export class AssistantDatabase {
|
|||||||
const { header } = save
|
const { header } = save
|
||||||
const existingConversation = findConversation.get(
|
const existingConversation = findConversation.get(
|
||||||
header.id
|
header.id
|
||||||
) as { channel: ProjectChannel | null } | undefined
|
) as { channel: ProjectChannel | null; context_state_json: string | null } | undefined
|
||||||
const updatedAt = new Date(header.updatedAt).toISOString()
|
const updatedAt = new Date(header.updatedAt).toISOString()
|
||||||
if (existingConversation?.channel) {
|
if (existingConversation?.channel) {
|
||||||
throw new Error('本地对话 ID 与远程对话冲突')
|
throw new Error('本地对话 ID 与远程对话冲突')
|
||||||
@@ -3386,7 +3405,9 @@ export class AssistantDatabase {
|
|||||||
header.projectId ?? null,
|
header.projectId ?? null,
|
||||||
serializeRuntimeSelection(header.runtimeSelection),
|
serializeRuntimeSelection(header.runtimeSelection),
|
||||||
header.knowledgeRetrievalMode ?? null,
|
header.knowledgeRetrievalMode ?? null,
|
||||||
serializeConversationContextState(header),
|
// The explicit switch owns this preference; a queued older header must not undo it.
|
||||||
|
serializeConversationContextState({ ...header,
|
||||||
|
storyGraphEnabled: parseConversationContextState(existingConversation.context_state_json).storyGraphEnabled }),
|
||||||
header.title,
|
header.title,
|
||||||
header.branch?.sourceConversationId ?? null,
|
header.branch?.sourceConversationId ?? null,
|
||||||
header.branch?.sourceTitle ?? null,
|
header.branch?.sourceTitle ?? null,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest'
|
|||||||
import type { AgentRuntime } from '../agent/runtime'
|
import type { AgentRuntime } from '../agent/runtime'
|
||||||
import type { SupervisionEvidence, SupervisionRunRequest } from '../../shared/supervision-contracts'
|
import type { SupervisionEvidence, SupervisionRunRequest } from '../../shared/supervision-contracts'
|
||||||
import { AssistantDatabase } from './assistant-database'
|
import { AssistantDatabase } from './assistant-database'
|
||||||
import { createProductionSupervisorService } from './supervision-production'
|
import { createProductionSupervisorService, createProductionSuggestionPhraser } from './supervision-production'
|
||||||
import { SupervisionModelPool } from './supervision-model-pool'
|
import { SupervisionModelPool } from './supervision-model-pool'
|
||||||
import { supervisionEntitySchema } from '../../shared/supervision-contracts'
|
import { supervisionEntitySchema } from '../../shared/supervision-contracts'
|
||||||
|
|
||||||
@@ -12,6 +12,88 @@ const cleanups: Array<() => void> = []
|
|||||||
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup() })
|
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup() })
|
||||||
const empty = { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
const empty = { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||||
|
|
||||||
|
it('keeps one model across extraction, navigation, stories and experiences, and reads the next selection on retry', async () => {
|
||||||
|
const f = fixture('A'.repeat(1500))
|
||||||
|
let profileId: string | null = 'first'
|
||||||
|
const phases: string[] = []
|
||||||
|
const dispose = vi.fn(async () => {})
|
||||||
|
const resolve = vi.fn(async (selected?: string | null) => ({
|
||||||
|
runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
|
||||||
|
run: async function* (input) {
|
||||||
|
expect(selected).toBe('first')
|
||||||
|
profileId = 'next'
|
||||||
|
let output: unknown
|
||||||
|
if (input.prompt.includes('You organise the GoodBuddy')) {
|
||||||
|
phases.push('stories')
|
||||||
|
output = { stories: [{ key: 'new_1', level: 'feature', name: 'Work' }],
|
||||||
|
assignments: [{ event: 'e_1', story: 'new_1' }, { event: 'e_2', story: 'new_1' }] }
|
||||||
|
} else if (input.prompt.includes('You distil reusable experience')) {
|
||||||
|
phases.push('experiences')
|
||||||
|
output = { experiences: [], applications: [] }
|
||||||
|
} else if (input.prompt.includes('These inputs are navigation summaries')) {
|
||||||
|
phases.push('navigation')
|
||||||
|
output = { summary: 'Combined', changeDigest: '', openItems: [] }
|
||||||
|
} else {
|
||||||
|
phases.push('extraction')
|
||||||
|
const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as SupervisionEvidence[]
|
||||||
|
output = { ...empty, events: [{ title: 'Decision', description: '', eventType: 'decision',
|
||||||
|
occurredAt: f.request.timeRange.from, entityIds: [], sourceReferenceIds: [evidence[0]!.id] }] }
|
||||||
|
}
|
||||||
|
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(output) }
|
||||||
|
yield { type: 'done', requestId: input.requestId }
|
||||||
|
}
|
||||||
|
} as AgentRuntime))
|
||||||
|
const pool = new SupervisionModelPool()
|
||||||
|
cleanups.push(() => pool.dispose())
|
||||||
|
const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelProfileId: profileId,
|
||||||
|
supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, experienceMinEvents: 2 } }), resolve, pool)
|
||||||
|
const result = await service.run(f.request)
|
||||||
|
expect(phases).toEqual(['extraction', 'extraction', 'navigation', 'stories', 'experiences'])
|
||||||
|
expect(resolve).toHaveBeenCalledExactlyOnceWith('first')
|
||||||
|
expect(dispose).toHaveBeenCalledOnce()
|
||||||
|
await service.organizeStoriesFor(result.runId!)
|
||||||
|
expect(resolve).toHaveBeenLastCalledWith('next')
|
||||||
|
expect(dispose).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('releases a failed review runtime and uses the current model when continuing saved work', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
let profileId: string | null = 'first'
|
||||||
|
const dispose = vi.fn(async () => {})
|
||||||
|
const resolve = vi.fn(async (selected?: string | null) => ({ runtimeId: 'model', capability: 'chat',
|
||||||
|
requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
|
||||||
|
run: async function* (input) {
|
||||||
|
if (selected === 'first') throw new Error('Provider unavailable')
|
||||||
|
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(empty) }
|
||||||
|
yield { type: 'done', requestId: input.requestId }
|
||||||
|
}
|
||||||
|
} as AgentRuntime))
|
||||||
|
const pool = new SupervisionModelPool()
|
||||||
|
cleanups.push(() => pool.dispose())
|
||||||
|
const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelProfileId: profileId }), resolve, pool)
|
||||||
|
await expect(service.run(f.request)).rejects.toThrow('Provider unavailable')
|
||||||
|
expect(dispose).toHaveBeenCalledOnce()
|
||||||
|
expect(service.execution().active).toBe(false)
|
||||||
|
profileId = null
|
||||||
|
await expect(service.resume(f.db.listSupervisionActivity()[0]!.id)).resolves.toMatchObject({ status: 'completed' })
|
||||||
|
expect(resolve.mock.calls).toEqual([['first'], [null]])
|
||||||
|
expect(dispose).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the supervisor selection for suggestions and disposes its runtime on failure', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
const dispose = vi.fn(async () => {})
|
||||||
|
const resolve = vi.fn(async () => ({ runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false, getStatus: vi.fn(), dispose,
|
||||||
|
run: async function* (input) { yield { type: 'error', requestId: input.requestId, message: 'Provider unavailable' } }
|
||||||
|
} as AgentRuntime))
|
||||||
|
const pool = new SupervisionModelPool()
|
||||||
|
cleanups.push(() => pool.dispose())
|
||||||
|
const phrase = createProductionSuggestionPhraser(f.db, async () => ({ supervisorModelProfileId: 'reviewer' }), resolve, pool)
|
||||||
|
await expect(phrase({ systemInstruction: 'Suggest', outputContract: '{}', candidates: [] })).rejects.toThrow('Provider unavailable')
|
||||||
|
expect(resolve).toHaveBeenCalledExactlyOnceWith('reviewer')
|
||||||
|
expect(dispose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
it('production six-leaf merge failure resumes navigation-only JSON and publishes all retained facts', async () => {
|
it('production six-leaf merge failure resumes navigation-only JSON and publishes all retained facts', async () => {
|
||||||
const f = fixture('A'.repeat(765))
|
const f = fixture('A'.repeat(765))
|
||||||
const now = Date.parse(f.request.timeRange.from) + 1
|
const now = Date.parse(f.request.timeRange.from) + 1
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
|
|||||||
function dependencies(
|
function dependencies(
|
||||||
database: AssistantDatabase,
|
database: AssistantDatabase,
|
||||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||||
resolveRuntime: () => Promise<AgentRuntime>,
|
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||||
pool: SupervisionModelPool,
|
pool: SupervisionModelPool,
|
||||||
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
||||||
): SupervisionModelDependencies {
|
): SupervisionModelDependencies {
|
||||||
@@ -98,11 +98,16 @@ function dependencies(
|
|||||||
export function createProductionSupervisorService(
|
export function createProductionSupervisorService(
|
||||||
database: AssistantDatabase,
|
database: AssistantDatabase,
|
||||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||||
resolveRuntime: () => Promise<AgentRuntime>,
|
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||||
pool: SupervisionModelPool,
|
pool: SupervisionModelPool,
|
||||||
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
||||||
): SupervisorService {
|
): SupervisorService {
|
||||||
const model = dependencies(database, getSettings, resolveRuntime, pool, persistUsage)
|
// The service admits only one execution at a time, including resume and story retry.
|
||||||
|
let runtime: AgentRuntime | undefined
|
||||||
|
const model = dependencies(database, getSettings, async () => {
|
||||||
|
if (!runtime) throw new Error('Supervisor model execution is not active')
|
||||||
|
return runtime
|
||||||
|
}, pool, persistUsage)
|
||||||
return new SupervisorService({ collect: async () => { throw new Error('Paged review collector required') } }, {
|
return new SupervisorService({ collect: async () => { throw new Error('Paged review collector required') } }, {
|
||||||
summarize: async request => runSupervisionModel(model, {
|
summarize: async request => runSupervisionModel(model, {
|
||||||
title: '监督者回顾', instructions: '根据有界证据整理监督者回顾',
|
title: '监督者回顾', instructions: '根据有界证据整理监督者回顾',
|
||||||
@@ -122,7 +127,15 @@ export function createProductionSupervisorService(
|
|||||||
start: (request, heartbeatRunId) => database.startSupervisionRun(request, heartbeatRunId),
|
start: (request, heartbeatRunId) => database.startSupervisionRun(request, heartbeatRunId),
|
||||||
fail: (runId, error) => database.failSupervisionRun(runId, error), noChange: runId => database.noChangeSupervisionRun(runId),
|
fail: (runId, error) => database.failSupervisionRun(runId, error), noChange: runId => database.noChangeSupervisionRun(runId),
|
||||||
candidates: async request => database.listSupervisionCandidates(request), save: async result => database.saveSupervisionResult(result)
|
candidates: async request => database.listSupervisionCandidates(request), save: async result => database.saveSupervisionResult(result)
|
||||||
}, { database: () => database.supervisionReviewStore(), configuration: async () => {
|
}, { database: () => database.supervisionReviewStore(), withExecution: async operation => {
|
||||||
|
runtime = await resolveRuntime((await getSettings())?.supervisorModelProfileId)
|
||||||
|
try { return await operation() }
|
||||||
|
finally {
|
||||||
|
const completed = runtime
|
||||||
|
runtime = undefined
|
||||||
|
await completed.dispose?.()
|
||||||
|
}
|
||||||
|
}, configuration: async () => {
|
||||||
const settings = await getSettings()
|
const settings = await getSettings()
|
||||||
return { ...supervisionReviewSettingsSchema.parse(settings?.supervisionReview ?? {}), version: 1,
|
return { ...supervisionReviewSettingsSchema.parse(settings?.supervisionReview ?? {}), version: 1,
|
||||||
timeoutSeconds: settings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
timeoutSeconds: settings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
|
||||||
@@ -163,16 +176,21 @@ async function assignStoriesStep(model: SupervisionModelDependencies, request: S
|
|||||||
export function createProductionSuggestionPhraser(
|
export function createProductionSuggestionPhraser(
|
||||||
database: AssistantDatabase,
|
database: AssistantDatabase,
|
||||||
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
|
||||||
resolveRuntime: () => Promise<AgentRuntime>,
|
resolveRuntime: (profileId?: string | null) => Promise<AgentRuntime>,
|
||||||
pool: SupervisionModelPool,
|
pool: SupervisionModelPool,
|
||||||
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
persistUsage?: (event: RuntimeModelUsageEvent) => void
|
||||||
): SuggestionPhraser {
|
): SuggestionPhraser {
|
||||||
const model = dependencies(database, getSettings, resolveRuntime, pool, persistUsage)
|
return async request => {
|
||||||
return async request => runSupervisionModel(model, {
|
const runtime = await resolveRuntime((await getSettings())?.supervisorModelProfileId)
|
||||||
title: '监督者建议', instructions: '根据已发布的图谱变化生成建议',
|
const model = dependencies(database, getSettings, async () => runtime, pool, persistUsage)
|
||||||
timeoutMessage: seconds => `监督者建议生成超过 ${seconds} 秒,已停止;回顾结果已保留`,
|
try {
|
||||||
authorizeTool: async name => { throw new Error(`监督者禁止调用工具: ${name}`) },
|
return await runSupervisionModel(model, {
|
||||||
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
|
title: '监督者建议', instructions: '根据已发布的图谱变化生成建议',
|
||||||
'CANDIDATES:', JSON.stringify(request.candidates), 'Return only JSON.'].join('\n\n')
|
timeoutMessage: seconds => `监督者建议生成超过 ${seconds} 秒,已停止;回顾结果已保留`,
|
||||||
})
|
authorizeTool: async name => { throw new Error(`监督者禁止调用工具: ${name}`) },
|
||||||
|
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
|
||||||
|
'CANDIDATES:', JSON.stringify(request.candidates), 'Return only JSON.'].join('\n\n')
|
||||||
|
})
|
||||||
|
} finally { await runtime.dispose?.() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export class SupervisorService {
|
|||||||
private readonly review?: {
|
private readonly review?: {
|
||||||
database: () => SupervisionReviewStore
|
database: () => SupervisionReviewStore
|
||||||
configuration: () => Promise<ReviewConfiguration>
|
configuration: () => Promise<ReviewConfiguration>
|
||||||
|
withExecution?: (operation: () => Promise<StoredSupervisionResult>) => Promise<StoredSupervisionResult>
|
||||||
/** Story assignment over published, still unassigned events of the scope. Runs inside the review slot. */
|
/** Story assignment over published, still unassigned events of the scope. Runs inside the review slot. */
|
||||||
stories?: (request: SupervisionRunRequest, config: ReviewConfiguration, signal: AbortSignal) => Promise<NonNullable<SupervisionReviewProgress['stories']>>
|
stories?: (request: SupervisionRunRequest, config: ReviewConfiguration, signal: AbortSignal) => Promise<NonNullable<SupervisionReviewProgress['stories']>>
|
||||||
}
|
}
|
||||||
@@ -234,7 +235,7 @@ export class SupervisorService {
|
|||||||
await this.active.settled.catch(() => undefined)
|
await this.active.settled.catch(() => undefined)
|
||||||
}
|
}
|
||||||
// Reserve before any asynchronous configuration, collection or resume work.
|
// Reserve before any asynchronous configuration, collection or resume work.
|
||||||
const active = { settled: Promise.resolve().then(operation) }
|
const active = { settled: Promise.resolve().then(() => this.review?.withExecution ? this.review.withExecution(operation) : operation()) }
|
||||||
this.active = active
|
this.active = active
|
||||||
try { return await active.settled }
|
try { return await active.settled }
|
||||||
finally { if (this.active === active) this.active = undefined }
|
finally { if (this.active === active) this.active = undefined }
|
||||||
|
|||||||
+4
-2
@@ -1057,7 +1057,8 @@ if (hasSingleInstanceLock) {
|
|||||||
{
|
{
|
||||||
observeFailure: observeDesktopFailure,
|
observeFailure: observeDesktopFailure,
|
||||||
storyGraphService: {
|
storyGraphService: {
|
||||||
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
available: async ({ runtimeTarget, conversationId }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
||||||
|
(!conversationId || startupAssistantDatabase.isConversationStoryGraphEnabled(conversationId)) &&
|
||||||
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
||||||
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal)
|
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal)
|
||||||
},
|
},
|
||||||
@@ -1540,7 +1541,8 @@ if (hasSingleInstanceLock) {
|
|||||||
createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, {
|
createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, {
|
||||||
observeFailure: observeDesktopFailure,
|
observeFailure: observeDesktopFailure,
|
||||||
storyGraphService: {
|
storyGraphService: {
|
||||||
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
available: async ({ runtimeTarget, conversationId }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
|
||||||
|
(!conversationId || startupAssistantDatabase.isConversationStoryGraphEnabled(conversationId)) &&
|
||||||
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
|
||||||
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal)
|
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraphAsync(name, input, projectId, signal)
|
||||||
},
|
},
|
||||||
|
|||||||
+72
-2
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type {} from '@testing-library/jest-dom/vitest'
|
import type {} from '@testing-library/jest-dom/vitest'
|
||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { createServer } from 'node:http'
|
||||||
import {
|
import {
|
||||||
mkdtemp,
|
mkdtemp,
|
||||||
mkdir,
|
mkdir,
|
||||||
@@ -21,6 +22,7 @@ import type {
|
|||||||
} from '../shared/assistant-contracts'
|
} from '../shared/assistant-contracts'
|
||||||
import {
|
import {
|
||||||
browserTabIdSchema,
|
browserTabIdSchema,
|
||||||
|
defaultModelProfileId,
|
||||||
defaultRuntimeSettings,
|
defaultRuntimeSettings,
|
||||||
runtimeSettingsInputSchema,
|
runtimeSettingsInputSchema,
|
||||||
type AgentEvent,
|
type AgentEvent,
|
||||||
@@ -4684,6 +4686,16 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
|||||||
expect(webContents.send).not.toHaveBeenCalled()
|
expect(webContents.send).not.toHaveBeenCalled()
|
||||||
expect(database.getConversation(conversationId)).toEqual(before)
|
expect(database.getConversation(conversationId)).toEqual(before)
|
||||||
|
|
||||||
|
const setStoryGraph = electronMocks.handlers.get(ipcChannels.conversationsSetStoryGraph)!
|
||||||
|
expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(true)
|
||||||
|
for (const enabled of [false, true]) {
|
||||||
|
setStoryGraph(event, { conversationId, enabled })
|
||||||
|
expect(database.isConversationStoryGraphEnabled(conversationId)).toBe(enabled)
|
||||||
|
expect(database.getConversation(conversationId)).toEqual({ ...before, storyGraphEnabled: enabled })
|
||||||
|
}
|
||||||
|
expect(() => setStoryGraph(event, { conversationId, enabled: 'false' })).toThrow()
|
||||||
|
expect(() => setStoryGraph({ ...event, sender: {} }, { conversationId, enabled: false })).toThrow()
|
||||||
|
|
||||||
expect(electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true)
|
expect(electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true)
|
||||||
expect(collect).toHaveBeenCalledOnce()
|
expect(collect).toHaveBeenCalledOnce()
|
||||||
expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce()
|
expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce()
|
||||||
@@ -5509,6 +5521,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
runtimeSelection: { provider: 'opencode' },
|
runtimeSelection: { provider: 'opencode' },
|
||||||
messages: []
|
messages: []
|
||||||
})),
|
})),
|
||||||
|
isConversationStoryGraphEnabled: vi.fn(() => true),
|
||||||
|
setConversationStoryGraphEnabled: vi.fn(),
|
||||||
getProject: vi.fn<
|
getProject: vi.fn<
|
||||||
(projectId: string) => Record<string, unknown>
|
(projectId: string) => Record<string, unknown>
|
||||||
>((projectId) => ({
|
>((projectId) => ({
|
||||||
@@ -5559,6 +5573,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
}
|
}
|
||||||
const getResolvedSettings = vi.fn(
|
const getResolvedSettings = vi.fn(
|
||||||
async (): Promise<Record<string, unknown>> => ({
|
async (): Promise<Record<string, unknown>> => ({
|
||||||
|
modelProfiles: [{ id: defaultModelProfileId, name: 'Default', modelName: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none' }],
|
||||||
|
defaultModelProfileId,
|
||||||
toolApproval,
|
toolApproval,
|
||||||
subagentSmartRoutingEnabled: smartRoutingEnabled
|
subagentSmartRoutingEnabled: smartRoutingEnabled
|
||||||
})
|
})
|
||||||
@@ -5634,6 +5650,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
args[45] = nativeClientCoordinator
|
args[45] = nativeClientCoordinator
|
||||||
args[41] = nativeTerminalManager
|
args[41] = nativeTerminalManager
|
||||||
if (applicationSettingsStore) args[15] = applicationSettingsStore
|
if (applicationSettingsStore) args[15] = applicationSettingsStore
|
||||||
|
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(runtime)
|
||||||
const dispose = registerIpcHandlers(...args)
|
const dispose = registerIpcHandlers(...args)
|
||||||
return {
|
return {
|
||||||
approvalBroker,
|
approvalBroker,
|
||||||
@@ -6396,6 +6413,57 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
} finally { await harness.dispose(); database.close() }
|
} finally { await harness.dispose(); database.close() }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('routes supervisor model selection through the real direct runtime to a saved connection', async () => {
|
||||||
|
const calls: Array<{ model: string; tools?: unknown; temperature?: number }> = []
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
let body = ''
|
||||||
|
for await (const chunk of request) body += String(chunk)
|
||||||
|
calls.push(JSON.parse(body))
|
||||||
|
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||||
|
response.end(`data: ${JSON.stringify({ choices: [{ delta: { content: JSON.stringify({ summary: 'Connection verified', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }, finish_reason: null }] })}\n\ndata: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`)
|
||||||
|
})
|
||||||
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
const address = server.address() as { port: number }
|
||||||
|
const database = new AssistantDatabase(':memory:')
|
||||||
|
database.initialize(process.cwd())
|
||||||
|
const now = Date.now()
|
||||||
|
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Review', updatedAt: now },
|
||||||
|
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'A saved decision', createdAt: now }] }])
|
||||||
|
const harness = createHarness({ runtimeId: 'opencode', capability: 'chat', run: vi.fn() }, undefined, 'always', undefined, false,
|
||||||
|
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
|
||||||
|
const actual = await vi.importActual<typeof import('./agent/create-runtime')>('./agent/create-runtime')
|
||||||
|
runtimeFactoryMocks.createModelProfileRuntime.mockImplementation(actual.createModelProfileRuntime)
|
||||||
|
const profile = { id: crypto.randomUUID(), name: 'Review connection',
|
||||||
|
baseUrl: `http://127.0.0.1:${address.port}/v1`, modelName: 'review-selected', protocol: 'openai-chat-completions', authentication: 'none',
|
||||||
|
requestHeaders: {}, requestBody: { temperature: 0.25 } }
|
||||||
|
const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'app-default' }
|
||||||
|
harness.getResolvedSettings.mockResolvedValue({ ...defaultRuntimeSettings, provider: 'opencode', modelProfiles: [profile, fallback], defaultModelProfileId: fallback.id })
|
||||||
|
const handler = electronMocks.handlers.get(ipcChannels.supervisionRun)!
|
||||||
|
const input = { trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange: {
|
||||||
|
from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString()
|
||||||
|
} }
|
||||||
|
try {
|
||||||
|
for (const supervisorModelProfileId of [profile.id, null]) {
|
||||||
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId })
|
||||||
|
await expect(handler(trustedEvent(harness.webContents), input)).resolves.toMatchObject({ status: 'completed' })
|
||||||
|
}
|
||||||
|
expect(calls.map(call => call.model)).toEqual(['review-selected', 'app-default'])
|
||||||
|
expect(calls.every(call => !call.tools && call.temperature === 0.25)).toBe(true)
|
||||||
|
for (const invalid of [undefined, { ...profile, protocol: 'openai-images-generations' }, { ...profile, authentication: 'api-key', apiKey: undefined }]) {
|
||||||
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true, supervisorModelProfileId: profile.id })
|
||||||
|
harness.getResolvedSettings.mockResolvedValue({ modelProfiles: invalid ? [invalid, fallback] : [fallback], defaultModelProfileId: fallback.id })
|
||||||
|
await expect(handler(trustedEvent(harness.webContents), input)).rejects.toThrow(/不可用|API Key/)
|
||||||
|
}
|
||||||
|
expect(calls).toHaveLength(2)
|
||||||
|
} finally {
|
||||||
|
await harness.dispose()
|
||||||
|
database.close()
|
||||||
|
server.closeAllConnections()
|
||||||
|
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||||
|
runtimeFactoryMocks.createModelProfileRuntime.mockReset()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => {
|
it.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => {
|
||||||
vi.useFakeTimers({ toFake: ['Date'] })
|
vi.useFakeTimers({ toFake: ['Date'] })
|
||||||
vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z'))
|
vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z'))
|
||||||
@@ -9549,16 +9617,18 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
const projectId = '00000000-0000-4000-8000-000000000088'
|
const projectId = '00000000-0000-4000-8000-000000000088'
|
||||||
for (const enabled of [true, false]) {
|
for (const [enabled, conversationEnabled] of [[true, true], [false, true], [true, false]]) {
|
||||||
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: enabled })
|
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: enabled })
|
||||||
|
harness.assistantDatabase.isConversationStoryGraphEnabled.mockReturnValue(conversationEnabled!)
|
||||||
const requestId = crypto.randomUUID()
|
const requestId = crypto.randomUUID()
|
||||||
await harness.handler!(trustedEvent(harness.webContents), { requestId, projectId, conversationId: 'graph-grant', prompt: 'Continue work', workMode: 'ask', knowledgeLibraryIds: [] })
|
await harness.handler!(trustedEvent(harness.webContents), { requestId, projectId, conversationId: 'graph-grant', prompt: 'Continue work', workMode: 'ask', knowledgeLibraryIds: [] })
|
||||||
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
|
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
|
||||||
}
|
}
|
||||||
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(expect.any(String), [], expect.any(AbortSignal), 'none',
|
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(expect.any(String), [], expect.any(AbortSignal), 'none',
|
||||||
undefined, undefined, undefined, undefined, undefined, { projectId, runtimeTarget: runtimeId })
|
undefined, undefined, undefined, undefined, undefined, { projectId, conversationId: 'graph-grant', runtimeTarget: runtimeId })
|
||||||
expect(received[0]).toMatchObject({ knowledgeCapabilityToken: 'graph-capability', storyGraphBinding: binding })
|
expect(received[0]).toMatchObject({ knowledgeCapabilityToken: 'graph-capability', storyGraphBinding: binding })
|
||||||
expect(received[1]).not.toHaveProperty('storyGraphBinding')
|
expect(received[1]).not.toHaveProperty('storyGraphBinding')
|
||||||
|
expect(received[2]).not.toHaveProperty('storyGraphBinding')
|
||||||
expect(gateway.bindRemoteStoryGraph).toHaveBeenCalledOnce()
|
expect(gateway.bindRemoteStoryGraph).toHaveBeenCalledOnce()
|
||||||
} finally { await harness.dispose() }
|
} finally { await harness.dispose() }
|
||||||
})
|
})
|
||||||
|
|||||||
+29
-6
@@ -137,6 +137,7 @@ import {
|
|||||||
conversationListRequestSchema,
|
conversationListRequestSchema,
|
||||||
conversationSearchRequestSchema,
|
conversationSearchRequestSchema,
|
||||||
conversationSetPinnedSchema,
|
conversationSetPinnedSchema,
|
||||||
|
conversationSetStoryGraphSchema,
|
||||||
type ConversationSnapshot,
|
type ConversationSnapshot,
|
||||||
localConversationSaveBatchSchema,
|
localConversationSaveBatchSchema,
|
||||||
memoryCreateSchema,
|
memoryCreateSchema,
|
||||||
@@ -2228,8 +2229,9 @@ export function registerIpcHandlers(
|
|||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
const notesCapability = await grantScopedDataCapability({
|
const notesCapability = await grantScopedDataCapability({
|
||||||
storyGraph: requestRuntimeTarget && (await applicationSettingsStore?.get())?.heartbeatEnabled
|
storyGraph: requestRuntimeTarget && (await applicationSettingsStore?.get())?.heartbeatEnabled &&
|
||||||
? { runtimeTarget: requestRuntimeTarget, projectId: schedule.projectId ?? undefined } : undefined,
|
assistantDatabase.isConversationStoryGraphEnabled(runtimeConversationId)
|
||||||
|
? { runtimeTarget: requestRuntimeTarget, projectId: schedule.projectId ?? undefined, conversationId: runtimeConversationId } : undefined,
|
||||||
obsidian: enabledBuiltinMcpServers.includes('obsidian')
|
obsidian: enabledBuiltinMcpServers.includes('obsidian')
|
||||||
? {
|
? {
|
||||||
settings: await capabilityService.getObsidianSettings(),
|
settings: await capabilityService.getObsidianSettings(),
|
||||||
@@ -3761,8 +3763,9 @@ export function registerIpcHandlers(
|
|||||||
const imageSaveAvailable = Boolean(imageToolBinding?.save && normalizedWorkMode === 'execute' &&
|
const imageSaveAvailable = Boolean(imageToolBinding?.save && normalizedWorkMode === 'execute' &&
|
||||||
await imageToolBinding.describeSave?.())
|
await imageToolBinding.describeSave?.())
|
||||||
const scopedCapability = await grantScopedDataCapability({
|
const scopedCapability = await grantScopedDataCapability({
|
||||||
storyGraph: selectedRuntimeTarget && applicationSettings?.heartbeatEnabled
|
storyGraph: selectedRuntimeTarget && applicationSettings?.heartbeatEnabled &&
|
||||||
? { runtimeTarget: selectedRuntimeTarget, projectId: enrichedRequest.projectId } : undefined,
|
assistantDatabase.isConversationStoryGraphEnabled(enrichedRequest.conversationId)
|
||||||
|
? { runtimeTarget: selectedRuntimeTarget, projectId: enrichedRequest.projectId, conversationId: enrichedRequest.conversationId } : undefined,
|
||||||
obsidian: enabledBuiltinMcpServers.includes('obsidian')
|
obsidian: enabledBuiltinMcpServers.includes('obsidian')
|
||||||
? {
|
? {
|
||||||
settings: await capabilityService.getObsidianSettings(),
|
settings: await capabilityService.getObsidianSettings(),
|
||||||
@@ -6383,11 +6386,31 @@ export function registerIpcHandlers(
|
|||||||
publishConversationChange()
|
publishConversationChange()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.conversationsSetStoryGraph,
|
||||||
|
(event, input: unknown) => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
const { conversationId, enabled } = conversationSetStoryGraphSchema.parse(input)
|
||||||
|
assistantDatabase.setConversationStoryGraphEnabled(conversationId, enabled)
|
||||||
|
publishConversationChange()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const resolveSupervisorRuntime = async (profileId?: string | null): Promise<AgentRuntime> => {
|
||||||
|
const settings = await settingsStore.getResolvedSettings()
|
||||||
|
const profile = settings.modelProfiles.find(candidate => candidate.id === (profileId ?? settings.defaultModelProfileId))
|
||||||
|
if (!profile || !isAgentRuntimeModelProtocol(profile.protocol)) {
|
||||||
|
throw new Error('监督者所选文本模型不存在或已不可用,请在监督者设置中重新选择模型。')
|
||||||
|
}
|
||||||
|
if (profile.authentication === 'api-key' && !profile.apiKey) {
|
||||||
|
throw new Error(`监督者模型连接“${profile.name}”未配置 API Key`)
|
||||||
|
}
|
||||||
|
return createModelProfileRuntime(settings.workspacePath, settings, profile)
|
||||||
|
}
|
||||||
const supervisorService = createProductionSupervisorService(assistantDatabase,
|
const supervisorService = createProductionSupervisorService(assistantDatabase,
|
||||||
async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool,
|
async () => applicationSettingsStore?.get(), resolveSupervisorRuntime, supervisionModelPool,
|
||||||
persistModelUsage)
|
persistModelUsage)
|
||||||
const suggestionPhraser = createProductionSuggestionPhraser(assistantDatabase,
|
const suggestionPhraser = createProductionSuggestionPhraser(assistantDatabase,
|
||||||
async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool,
|
async () => applicationSettingsStore?.get(), resolveSupervisorRuntime, supervisionModelPool,
|
||||||
persistModelUsage)
|
persistModelUsage)
|
||||||
|
|
||||||
registerHandler(
|
registerHandler(
|
||||||
|
|||||||
@@ -1135,6 +1135,9 @@ const desktopApi: DesktopApi = {
|
|||||||
setPinned: async (input) => {
|
setPinned: async (input) => {
|
||||||
await ipcRenderer.invoke(ipcChannels.conversationsSetPinned, input)
|
await ipcRenderer.invoke(ipcChannels.conversationsSetPinned, input)
|
||||||
},
|
},
|
||||||
|
setStoryGraph: async (input) => {
|
||||||
|
await ipcRenderer.invoke(ipcChannels.conversationsSetStoryGraph, input)
|
||||||
|
},
|
||||||
branchLocal: (input: ConversationBranchInput) =>
|
branchLocal: (input: ConversationBranchInput) =>
|
||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
ipcChannels.conversationsBranchLocal,
|
ipcChannels.conversationsBranchLocal,
|
||||||
|
|||||||
@@ -586,6 +586,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
|||||||
replace: vi.fn(async () => {}),
|
replace: vi.fn(async () => {}),
|
||||||
saveLocal: vi.fn(async () => {}),
|
saveLocal: vi.fn(async () => {}),
|
||||||
setPinned: vi.fn(async () => {}),
|
setPinned: vi.fn(async () => {}),
|
||||||
|
setStoryGraph: vi.fn(async () => {}),
|
||||||
branchLocal: vi.fn(async (input) => ({
|
branchLocal: vi.fn(async (input) => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
branch: {
|
branch: {
|
||||||
@@ -8544,6 +8545,33 @@ describe("App", () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("saves the story graph switch through the conversation API and restores it on reload", async () => {
|
||||||
|
installRemoteProjectsSetting(false);
|
||||||
|
const id = "00000000-0000-4000-8000-000000000311";
|
||||||
|
const saved = { id, projectId: project.id, title: "Story preference", updatedAt: 1, messages: [] };
|
||||||
|
vi.mocked(api.conversations.list).mockResolvedValue([saved]);
|
||||||
|
const pending = deferred<void>();
|
||||||
|
vi.mocked(api.conversations.setStoryGraph).mockImplementationOnce(() => pending.promise);
|
||||||
|
const view = render(<App />);
|
||||||
|
await screen.findByLabelText("向 GoodBuddy 提问");
|
||||||
|
openComposerOptions();
|
||||||
|
const toggle = await screen.findByRole("switch", { name: "使用故事图谱" });
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
await waitFor(() => expect(api.conversations.setStoryGraph).toHaveBeenCalledWith({ conversationId: id, enabled: false }));
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
expect(toggle).toBeDisabled();
|
||||||
|
await act(async () => pending.resolve());
|
||||||
|
await waitFor(() => { expect(toggle).not.toBeChecked(); expect(toggle).toBeEnabled(); });
|
||||||
|
view.unmount();
|
||||||
|
vi.mocked(api.conversations.list).mockResolvedValue([{ ...saved, storyGraphEnabled: false }]);
|
||||||
|
render(<App />);
|
||||||
|
await screen.findByLabelText("向 GoodBuddy 提问");
|
||||||
|
openComposerOptions();
|
||||||
|
expect(await screen.findByRole("switch", { name: "使用故事图谱" })).not.toBeChecked();
|
||||||
|
expect(api.agent.run).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("groups composer tools and exposes clear control descriptions", async () => {
|
it("groups composer tools and exposes clear control descriptions", async () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
|
|||||||
@@ -853,6 +853,7 @@ function toConversationSnapshots(
|
|||||||
workMode: conversation.workMode,
|
workMode: conversation.workMode,
|
||||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||||
|
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||||
contextMetrics: conversation.contextMetrics,
|
contextMetrics: conversation.contextMetrics,
|
||||||
contextCompressionState: conversation.contextCompressionState,
|
contextCompressionState: conversation.contextCompressionState,
|
||||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||||
@@ -6666,6 +6667,16 @@ function App(): React.JSX.Element {
|
|||||||
openImageViewer,
|
openImageViewer,
|
||||||
openModelSettings: openImageModelSettings,
|
openModelSettings: openImageModelSettings,
|
||||||
setEnabledKnowledgeLibraryIds,
|
setEnabledKnowledgeLibraryIds,
|
||||||
|
setStoryGraphEnabled: async (conversationId, enabled) => {
|
||||||
|
persistLocalConversationChanges();
|
||||||
|
await conversationPersistence.enqueue(async () => {
|
||||||
|
await window.goodbuddy.conversations.setStoryGraph({ conversationId, enabled });
|
||||||
|
setConversations((current) => current.map((conversation) =>
|
||||||
|
conversation.id === conversationId
|
||||||
|
? { ...conversation, storyGraphEnabled: enabled, updatedAt: Date.now() }
|
||||||
|
: conversation));
|
||||||
|
});
|
||||||
|
},
|
||||||
setKnowledgeRetrievalMode: (mode) =>
|
setKnowledgeRetrievalMode: (mode) =>
|
||||||
setConversations((current) =>
|
setConversations((current) =>
|
||||||
current.map((conversation) =>
|
current.map((conversation) =>
|
||||||
@@ -7463,6 +7474,7 @@ function App(): React.JSX.Element {
|
|||||||
selectedRuntimeAgent={selectedRuntimeAgent}
|
selectedRuntimeAgent={selectedRuntimeAgent}
|
||||||
selectedRuntimeCommand={selectedRuntimeCommand}
|
selectedRuntimeCommand={selectedRuntimeCommand}
|
||||||
selectingContextFiles={selectingContextFiles}
|
selectingContextFiles={selectingContextFiles}
|
||||||
|
supervisorEnabled={applicationSettings?.heartbeatEnabled === true}
|
||||||
updateAttachmentBusy={updateAttachmentBusy}
|
updateAttachmentBusy={updateAttachmentBusy}
|
||||||
voiceListening={voiceListening}
|
voiceListening={voiceListening}
|
||||||
voiceRecording={voiceRecording}
|
voiceRecording={voiceRecording}
|
||||||
@@ -7569,6 +7581,7 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isApplicationEnabled(applicationSettings, 'heartbeat') ? <HeartbeatCenter
|
{isApplicationEnabled(applicationSettings, 'heartbeat') ? <HeartbeatCenter
|
||||||
|
runtimeSettings={runtimeSettings}
|
||||||
applicationSettings={applicationSettings}
|
applicationSettings={applicationSettings}
|
||||||
applicationSettingsPending={applicationSettingsPending}
|
applicationSettingsPending={applicationSettingsPending}
|
||||||
applicationSettingsLocked={applicationSettingsUnconfirmed}
|
applicationSettingsLocked={applicationSettingsUnconfirmed}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { createRef, Profiler } from "react";
|
import { createRef, Profiler } from "react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Conversation } from "./chat-conversation";
|
import type { Conversation } from "./chat-conversation";
|
||||||
@@ -98,6 +98,37 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Composer render boundary", () => {
|
describe("Composer render boundary", () => {
|
||||||
|
it("hides the story graph switch with Supervisor off and keeps conversation state while saving or failing", async () => {
|
||||||
|
const { props, rerender, store } = setup();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Options" }));
|
||||||
|
expect(screen.queryByRole("switch", { name: "Use story graph" })).toBeNull();
|
||||||
|
let finish!: () => void;
|
||||||
|
const save = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
|
||||||
|
const notify = vi.fn();
|
||||||
|
const next = { ...props, supervisorEnabled: true, actions: { ...actions, setStoryGraphEnabled: save, notify } };
|
||||||
|
rerender(<Composer {...next} />);
|
||||||
|
const toggle = screen.getByRole("switch", { name: "Use story graph" });
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
expect(toggle.closest("label")).toHaveClass("toggle-row");
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(save).toHaveBeenCalledWith("a", false);
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
expect(toggle).toBeDisabled();
|
||||||
|
await act(async () => {
|
||||||
|
store.set(current => current.map(item => item.id === "a" ? { ...item, storyGraphEnabled: false } : item));
|
||||||
|
finish();
|
||||||
|
});
|
||||||
|
await waitFor(() => { expect(toggle).not.toBeChecked(); expect(toggle).toBeEnabled(); });
|
||||||
|
save.mockRejectedValueOnce(new Error("Save failed"));
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
await waitFor(() => { expect(toggle).toBeEnabled(); expect(toggle).not.toBeChecked(); expect(notify).toHaveBeenCalledWith({ tone: "error", message: "Save failed" }); });
|
||||||
|
rerender(<Composer {...next} conversationId="b" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Options" }));
|
||||||
|
expect(screen.getByRole("switch", { name: "Use story graph" })).toBeChecked();
|
||||||
|
rerender(<Composer {...next} supervisorEnabled={false} />);
|
||||||
|
expect(screen.queryByRole("switch", { name: "Use story graph", hidden: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not re-render when another conversation changes", () => {
|
it("does not re-render when another conversation changes", () => {
|
||||||
const { renders, store } = setup();
|
const { renders, store } = setup();
|
||||||
act(() => store.set((current) => current.map((item) =>
|
act(() => store.set((current) => current.map((item) =>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -127,6 +128,7 @@ export type ComposerProps = {
|
|||||||
selectedRuntimeAgent: string;
|
selectedRuntimeAgent: string;
|
||||||
selectedRuntimeCommand: string;
|
selectedRuntimeCommand: string;
|
||||||
selectingContextFiles: boolean;
|
selectingContextFiles: boolean;
|
||||||
|
supervisorEnabled?: boolean;
|
||||||
updateAttachmentBusy: (busy: boolean) => void;
|
updateAttachmentBusy: (busy: boolean) => void;
|
||||||
voiceListening: boolean;
|
voiceListening: boolean;
|
||||||
voiceRecording: boolean;
|
voiceRecording: boolean;
|
||||||
@@ -194,6 +196,7 @@ export const Composer = memo(function Composer({
|
|||||||
selectedRuntimeAgent,
|
selectedRuntimeAgent,
|
||||||
selectedRuntimeCommand,
|
selectedRuntimeCommand,
|
||||||
selectingContextFiles,
|
selectingContextFiles,
|
||||||
|
supervisorEnabled,
|
||||||
updateAttachmentBusy,
|
updateAttachmentBusy,
|
||||||
voiceListening,
|
voiceListening,
|
||||||
voiceRecording,
|
voiceRecording,
|
||||||
@@ -202,6 +205,7 @@ export const Composer = memo(function Composer({
|
|||||||
}: ComposerProps): React.JSX.Element {
|
}: ComposerProps): React.JSX.Element {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const view = useComposerConversationView(conversationStore, conversationId);
|
const view = useComposerConversationView(conversationStore, conversationId);
|
||||||
|
const [storyGraphSaving, setStoryGraphSaving] = useState(false);
|
||||||
const isRunning = view?.running ?? false;
|
const isRunning = view?.running ?? false;
|
||||||
// Popups belong to one conversation, run state and view, like the former
|
// Popups belong to one conversation, run state and view, like the former
|
||||||
// reset in App: changing any of them shows every popup closed.
|
// reset in App: changing any of them shows every popup closed.
|
||||||
@@ -329,7 +333,7 @@ export const Composer = memo(function Composer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
composerOptionsRef.current?.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus();
|
composerOptionsRef.current?.querySelector<HTMLElement>("input:not(:disabled), button:not(:disabled)")?.focus();
|
||||||
});
|
});
|
||||||
document.addEventListener("pointerdown", dismissOutside);
|
document.addEventListener("pointerdown", dismissOutside);
|
||||||
document.addEventListener("focusin", dismissOutside);
|
document.addEventListener("focusin", dismissOutside);
|
||||||
@@ -858,6 +862,23 @@ export const Composer = memo(function Composer({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>{t("composer.settings")}</strong>
|
<strong>{t("composer.settings")}</strong>
|
||||||
|
{supervisorEnabled && (
|
||||||
|
<label className="toggle-row">
|
||||||
|
<span>{t("composer.storyGraph.label")}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
checked={view?.storyGraphEnabled !== false}
|
||||||
|
disabled={storyGraphSaving}
|
||||||
|
onChange={(event) => {
|
||||||
|
setStoryGraphSaving(true);
|
||||||
|
void actions.setStoryGraphEnabled(conversationId, event.target.checked)
|
||||||
|
.catch((error: unknown) => actions.notify({ tone: "error", message: error instanceof Error ? error.message : String(error) }))
|
||||||
|
.finally(() => setStoryGraphSaving(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
{knowledgeLibraries.length > 0 && (
|
{knowledgeLibraries.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="knowledge-scope"
|
className="knowledge-scope"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { PageShell } from './WorkspacePrimitives'
|
|||||||
import i18n from './i18n'
|
import i18n from './i18n'
|
||||||
import { WorkspaceUnsavedChangesContext } from './workspace-unsaved-changes'
|
import { WorkspaceUnsavedChangesContext } from './workspace-unsaved-changes'
|
||||||
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../shared/application-settings-contracts'
|
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../shared/application-settings-contracts'
|
||||||
|
import type { RuntimeSettings } from '../../shared/contracts'
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
@@ -334,6 +335,40 @@ describe('HeartbeatCenter', () => {
|
|||||||
view.rerender(<HeartbeatCenter {...props} applicationSettings={{ ...applicationSettings, supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 4 }} />)
|
view.rerender(<HeartbeatCenter {...props} applicationSettings={{ ...applicationSettings, supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 4 }} />)
|
||||||
expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled()
|
expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
it.each(['zh-CN', 'en-US'])('saves an existing text model reference, retains failed drafts, and restores app default (%s)', async language => {
|
||||||
|
await i18n.changeLanguage(language)
|
||||||
|
const t = (key: string) => i18n.t(key, { ns: 'heartbeat' })
|
||||||
|
const profileId = '00000000-0000-4000-8000-000000000201'
|
||||||
|
const applicationSettings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
|
||||||
|
const runtimeSettings = { modelProfiles: [
|
||||||
|
{ id: profileId, name: 'Reviewer', modelName: 'review-model', protocol: 'openai-chat-completions' },
|
||||||
|
{ id: 'image', name: 'Image only', modelName: 'image-model', protocol: 'openai-images-generations' }
|
||||||
|
] } as RuntimeSettings
|
||||||
|
const onUpdateApplicationSettings = vi.fn(async () => false)
|
||||||
|
const props = createProps({ applicationSettings, runtimeSettings, onUpdateApplicationSettings })
|
||||||
|
const view = render(<HeartbeatCenter {...props} />, false)
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: t('supervisor.settings') }))
|
||||||
|
const selector = screen.getByRole('combobox', { name: t('timeouts.model') })
|
||||||
|
expect(selector).toHaveValue('')
|
||||||
|
expect(within(selector).queryByText(/Image only/)).not.toBeInTheDocument()
|
||||||
|
fireEvent.change(selector, { target: { value: profileId } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: t('timeouts.save') }))
|
||||||
|
await waitFor(() => expect(onUpdateApplicationSettings).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
supervisorModelProfileId: profileId, supervisorOrganizeTimeoutSeconds: 240, supervisorModelConcurrency: 1
|
||||||
|
}))
|
||||||
|
view.rerender(<HeartbeatCenter {...props} applicationSettingsError="Save failed" />)
|
||||||
|
expect(selector).toHaveValue(profileId)
|
||||||
|
view.rerender(<HeartbeatCenter {...props} applicationSettings={{ ...applicationSettings, supervisorModelProfileId: profileId }} />)
|
||||||
|
expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled()
|
||||||
|
view.rerender(<HeartbeatCenter {...props} runtimeSettings={{ ...runtimeSettings, modelProfiles: [] }} applicationSettings={{ ...applicationSettings, supervisorModelProfileId: profileId }} />)
|
||||||
|
expect(within(selector).getByRole('option', { name: t('timeouts.unavailableModel') })).toBeDisabled()
|
||||||
|
fireEvent.change(selector, { target: { value: '' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: t('timeouts.save') }))
|
||||||
|
await waitFor(() => expect(onUpdateApplicationSettings).toHaveBeenLastCalledWith({
|
||||||
|
supervisorModelProfileId: null, supervisorOrganizeTimeoutSeconds: 240, supervisorModelConcurrency: 1
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
it.each(['zh-CN', 'en-US'])('opens complete automatic supervision settings without creating defaults (%s)', async (language) => {
|
it.each(['zh-CN', 'en-US'])('opens complete automatic supervision settings without creating defaults (%s)', async (language) => {
|
||||||
await i18n.changeLanguage(language)
|
await i18n.changeLanguage(language)
|
||||||
const props = createProps({ configs: [], runs: [], entries: [] })
|
const props = createProps({ configs: [], runs: [], entries: [] })
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
HeartbeatUpdateInput
|
HeartbeatUpdateInput
|
||||||
} from '../../shared/assistant-contracts'
|
} from '../../shared/assistant-contracts'
|
||||||
import { HeartbeatSettings } from './HeartbeatSettings'
|
import { HeartbeatSettings } from './HeartbeatSettings'
|
||||||
|
import { isAgentRuntimeModelProtocol, type RuntimeSettings } from '../../shared/contracts'
|
||||||
import { defaultSupervisionTimeoutSeconds, supervisionTimeoutSecondsSchema, defaultSupervisorModelConcurrency, supervisorModelConcurrencySchema, type ApplicationSettings, type ApplicationSettingsUpdate } from '../../shared/application-settings-contracts'
|
import { defaultSupervisionTimeoutSeconds, supervisionTimeoutSecondsSchema, defaultSupervisorModelConcurrency, supervisorModelConcurrencySchema, type ApplicationSettings, type ApplicationSettingsUpdate } from '../../shared/application-settings-contracts'
|
||||||
import { SupervisionReviewSettings, type SupervisionSettingsSection } from './SupervisionReviewSettings'
|
import { SupervisionReviewSettings, type SupervisionSettingsSection } from './SupervisionReviewSettings'
|
||||||
import { SupervisorWorkspace, type SupervisionGraphNavigation } from './SupervisorWorkspace'
|
import { SupervisorWorkspace, type SupervisionGraphNavigation } from './SupervisorWorkspace'
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
} from './WorkspacePrimitives'
|
} from './WorkspacePrimitives'
|
||||||
|
|
||||||
export type HeartbeatCenterProps = {
|
export type HeartbeatCenterProps = {
|
||||||
|
runtimeSettings?: RuntimeSettings
|
||||||
applicationSettings?: ApplicationSettings
|
applicationSettings?: ApplicationSettings
|
||||||
applicationSettingsPending?: boolean
|
applicationSettingsPending?: boolean
|
||||||
applicationSettingsLocked?: boolean
|
applicationSettingsLocked?: boolean
|
||||||
@@ -200,24 +202,37 @@ function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Elemen
|
|||||||
const { t } = useTranslation('heartbeat')
|
const { t } = useTranslation('heartbeat')
|
||||||
const supervisor = props.applicationSettings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds
|
const supervisor = props.applicationSettings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds
|
||||||
const concurrency = props.applicationSettings?.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency
|
const concurrency = props.applicationSettings?.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency
|
||||||
|
const profileId = props.applicationSettings?.supervisorModelProfileId ?? ''
|
||||||
|
const profiles = useMemo(() => props.runtimeSettings?.modelProfiles.filter(profile => isAgentRuntimeModelProtocol(profile.protocol)) ?? [], [props.runtimeSettings])
|
||||||
|
const [model, setModel] = useState(profileId)
|
||||||
const [parallel, setParallel] = useState(String(concurrency))
|
const [parallel, setParallel] = useState(String(concurrency))
|
||||||
const [organize, setOrganize] = useState(String(supervisor))
|
const [organize, setOrganize] = useState(String(supervisor))
|
||||||
const [saved, setSaved] = useState({ supervisor, concurrency })
|
const [saved, setSaved] = useState({ supervisor, concurrency, profileId })
|
||||||
if (saved.supervisor !== supervisor || saved.concurrency !== concurrency) {
|
if (saved.supervisor !== supervisor || saved.concurrency !== concurrency || saved.profileId !== profileId) {
|
||||||
setSaved({ supervisor, concurrency })
|
setSaved({ supervisor, concurrency, profileId })
|
||||||
|
setModel(profileId)
|
||||||
setParallel(String(concurrency))
|
setParallel(String(concurrency))
|
||||||
setOrganize(String(supervisor))
|
setOrganize(String(supervisor))
|
||||||
}
|
}
|
||||||
useWorkspaceUnsavedChanges(organize !== String(supervisor) || parallel !== String(concurrency))
|
useWorkspaceUnsavedChanges(organize !== String(supervisor) || parallel !== String(concurrency) || model !== profileId)
|
||||||
const valid = supervisionTimeoutSecondsSchema.safeParse(Number(organize)).success &&
|
const valid = supervisionTimeoutSecondsSchema.safeParse(Number(organize)).success &&
|
||||||
supervisorModelConcurrencySchema.safeParse(Number(parallel)).success
|
supervisorModelConcurrencySchema.safeParse(Number(parallel)).success
|
||||||
const disabled = props.applicationSettingsPending || props.applicationSettingsLocked || !props.applicationSettings || !props.onUpdateApplicationSettings
|
const disabled = props.applicationSettingsPending || props.applicationSettingsLocked || !props.applicationSettings || !props.onUpdateApplicationSettings
|
||||||
return <form className="heartbeat-settings heartbeat-settings__editor" onSubmit={(event) => {
|
return <form className="heartbeat-settings heartbeat-settings__editor" onSubmit={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!valid || disabled) return
|
if (!valid || disabled) return
|
||||||
void props.onUpdateApplicationSettings?.({ supervisorOrganizeTimeoutSeconds: Number(organize), supervisorModelConcurrency: Number(parallel) })
|
void props.onUpdateApplicationSettings?.({ supervisorOrganizeTimeoutSeconds: Number(organize), supervisorModelConcurrency: Number(parallel),
|
||||||
|
...(model !== profileId ? { supervisorModelProfileId: model || null } : {}) })
|
||||||
}}>
|
}}>
|
||||||
<h2>{t('timeouts.title')}</h2>
|
<h2>{t('timeouts.title')}</h2>
|
||||||
|
<label className="heartbeat-settings__field">{t('timeouts.model')}
|
||||||
|
<select value={model} aria-describedby="supervisor-model-help" disabled={disabled || !props.runtimeSettings} onChange={event => setModel(event.target.value)}>
|
||||||
|
<option value="">{t('timeouts.followDefault')}</option>
|
||||||
|
{model && !profiles.some(profile => profile.id === model) && <option value={model} disabled>{t('timeouts.unavailableModel')}</option>}
|
||||||
|
{profiles.map(profile => <option key={profile.id} value={profile.id}>{profile.name} ({profile.modelName})</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p id="supervisor-model-help">{t('timeouts.modelHelp')}</p>
|
||||||
<p>{t('timeouts.help')}</p>
|
<p>{t('timeouts.help')}</p>
|
||||||
<label className="heartbeat-settings__field">{t('timeouts.organize')}
|
<label className="heartbeat-settings__field">{t('timeouts.organize')}
|
||||||
<input type="number" min={30} max={600} step={1} value={organize} disabled={disabled}
|
<input type="number" min={30} max={600} step={1} value={organize} disabled={disabled}
|
||||||
@@ -234,7 +249,7 @@ function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Elemen
|
|||||||
<p>{props.applicationSettingsError}</p>
|
<p>{props.applicationSettingsError}</p>
|
||||||
<button className="secondary-button" type="button" disabled={props.applicationSettingsPending} onClick={props.onRetryApplicationSettings}>{t('center.actions.retry')}</button>
|
<button className="secondary-button" type="button" disabled={props.applicationSettingsPending} onClick={props.onRetryApplicationSettings}>{t('center.actions.retry')}</button>
|
||||||
</div>}
|
</div>}
|
||||||
<button className="primary-button" type="submit" disabled={disabled || !valid || (Number(organize) === supervisor && Number(parallel) === concurrency)}>{t('timeouts.save')}</button>
|
<button className="primary-button" type="submit" disabled={disabled || !valid || (Number(organize) === supervisor && Number(parallel) === concurrency && model === profileId)}>{t('timeouts.save')}</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
function HeartbeatSections({
|
function HeartbeatSections({
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export function toLocalConversationHeader(
|
|||||||
workMode: conversation.workMode,
|
workMode: conversation.workMode,
|
||||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||||
|
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||||
contextMetrics: conversation.contextMetrics,
|
contextMetrics: conversation.contextMetrics,
|
||||||
contextCompressionState: conversation.contextCompressionState,
|
contextCompressionState: conversation.contextCompressionState,
|
||||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export function useActiveConversationView(
|
|||||||
*/
|
*/
|
||||||
export type ComposerConversationView = Pick<
|
export type ComposerConversationView = Pick<
|
||||||
Conversation,
|
Conversation,
|
||||||
| "id" | "runtimeSelection" | "knowledgeLibraryIds" | "knowledgeRetrievalMode"
|
| "id" | "runtimeSelection" | "knowledgeLibraryIds" | "knowledgeRetrievalMode" | "storyGraphEnabled"
|
||||||
| "remote" | "contextMetrics" | "contextCompressionState"
|
| "remote" | "contextMetrics" | "contextCompressionState"
|
||||||
> & { running: boolean };
|
> & { running: boolean };
|
||||||
|
|
||||||
@@ -263,6 +263,7 @@ export function selectComposerConversationView(
|
|||||||
runtimeSelection: conversation.runtimeSelection,
|
runtimeSelection: conversation.runtimeSelection,
|
||||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||||
|
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||||
remote: conversation.remote,
|
remote: conversation.remote,
|
||||||
contextMetrics: conversation.contextMetrics,
|
contextMetrics: conversation.contextMetrics,
|
||||||
contextCompressionState: conversation.contextCompressionState,
|
contextCompressionState: conversation.contextCompressionState,
|
||||||
@@ -277,6 +278,7 @@ export function sameComposerConversationView(
|
|||||||
if (left === right) return true;
|
if (left === right) return true;
|
||||||
if (!left || !right) return false;
|
if (!left || !right) return false;
|
||||||
return left.id === right.id && left.running === right.running &&
|
return left.id === right.id && left.running === right.running &&
|
||||||
|
left.storyGraphEnabled === right.storyGraphEnabled &&
|
||||||
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
|
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
|
||||||
sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) &&
|
sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) &&
|
||||||
(left.knowledgeLibraryIds === undefined) === (right.knowledgeLibraryIds === undefined) &&
|
(left.knowledgeLibraryIds === undefined) === (right.knowledgeLibraryIds === undefined) &&
|
||||||
|
|||||||
@@ -563,6 +563,7 @@ export const app = {
|
|||||||
},
|
},
|
||||||
removeAttachment: 'Remove {{name}}',
|
removeAttachment: 'Remove {{name}}',
|
||||||
settings: 'Conversation settings',
|
settings: 'Conversation settings',
|
||||||
|
storyGraph: { label: 'Use story graph' },
|
||||||
options: 'Options',
|
options: 'Options',
|
||||||
expertLabel: 'Expert role',
|
expertLabel: 'Expert role',
|
||||||
modeLabel: 'Work mode',
|
modeLabel: 'Work mode',
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export const heartbeat = {
|
|||||||
dismiss: 'Dismiss'
|
dismiss: 'Dismiss'
|
||||||
},
|
},
|
||||||
timeouts: {
|
timeouts: {
|
||||||
|
model: 'Model',
|
||||||
|
followDefault: 'Follow app default',
|
||||||
|
unavailableModel: 'Selected model is unavailable',
|
||||||
|
modelHelp: 'Uses an existing text model for reviews, summaries, stories, experiences, and suggestions. Saving affects the next execution, including continue or retry; a review already running keeps its model.',
|
||||||
title: 'Model timeouts and concurrency',
|
title: 'Model timeouts and concurrency',
|
||||||
report: 'Heartbeat report timeout (seconds)',
|
report: 'Heartbeat report timeout (seconds)',
|
||||||
organize: 'Supervisor organize timeout (seconds)',
|
organize: 'Supervisor organize timeout (seconds)',
|
||||||
|
|||||||
@@ -543,6 +543,7 @@ export const app = {
|
|||||||
},
|
},
|
||||||
removeAttachment: '移除 {{name}}',
|
removeAttachment: '移除 {{name}}',
|
||||||
settings: '对话设置',
|
settings: '对话设置',
|
||||||
|
storyGraph: { label: '使用故事图谱' },
|
||||||
options: '选项',
|
options: '选项',
|
||||||
expertLabel: '专家角色',
|
expertLabel: '专家角色',
|
||||||
modeLabel: '工作模式',
|
modeLabel: '工作模式',
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export const heartbeat = {
|
|||||||
dismiss: '忽略'
|
dismiss: '忽略'
|
||||||
},
|
},
|
||||||
timeouts: {
|
timeouts: {
|
||||||
|
model: '模型',
|
||||||
|
followDefault: '跟随应用默认',
|
||||||
|
unavailableModel: '所选模型已不可用',
|
||||||
|
modelHelp: '复用已有文本模型,用于回顾、汇总、故事、经验和建议。保存后从下次执行生效,包括继续或重试;正在运行的回顾保持原模型。',
|
||||||
title: '模型超时与并发',
|
title: '模型超时与并发',
|
||||||
report: '心跳报告超时(秒)',
|
report: '心跳报告超时(秒)',
|
||||||
organize: '监督者整理超时(秒)',
|
organize: '监督者整理超时(秒)',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type ComposerActions = {
|
|||||||
openModelSettings: () => void;
|
openModelSettings: () => void;
|
||||||
setEnabledKnowledgeLibraryIds: (update: SetStateAction<string[]>) => void;
|
setEnabledKnowledgeLibraryIds: (update: SetStateAction<string[]>) => void;
|
||||||
setKnowledgeRetrievalMode: (mode: "auto" | "always") => void;
|
setKnowledgeRetrievalMode: (mode: "auto" | "always") => void;
|
||||||
|
setStoryGraphEnabled: (conversationId: string, enabled: boolean) => Promise<void>;
|
||||||
selectExpert: (expertId: string) => void;
|
selectExpert: (expertId: string) => void;
|
||||||
selectRuntimeAgent: (agentId: string) => void;
|
selectRuntimeAgent: (agentId: string) => void;
|
||||||
selectContinuePreset: (presetId: string) => void;
|
selectContinuePreset: (presetId: string) => void;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const applicationPreferencesSchema = z
|
|||||||
heartbeatReportTimeoutSeconds: supervisionTimeoutSecondsSchema.optional(),
|
heartbeatReportTimeoutSeconds: supervisionTimeoutSecondsSchema.optional(),
|
||||||
supervisorOrganizeTimeoutSeconds: supervisionTimeoutSecondsSchema.optional(),
|
supervisorOrganizeTimeoutSeconds: supervisionTimeoutSecondsSchema.optional(),
|
||||||
supervisorModelConcurrency: supervisorModelConcurrencySchema.optional(),
|
supervisorModelConcurrency: supervisorModelConcurrencySchema.optional(),
|
||||||
|
supervisorModelProfileId: z.string().uuid().nullable().optional(),
|
||||||
supervisionReview: supervisionReviewSettingsSchema.optional(),
|
supervisionReview: supervisionReviewSettingsSchema.optional(),
|
||||||
magicNotesShowIncompleteTodoCount: z.boolean().default(true),
|
magicNotesShowIncompleteTodoCount: z.boolean().default(true),
|
||||||
magicNoteCommentMode: magicNoteCommentModeSchema.default('immediate'),
|
magicNoteCommentMode: magicNoteCommentModeSchema.default('immediate'),
|
||||||
|
|||||||
@@ -620,6 +620,7 @@ export const conversationSnapshotSchema = z
|
|||||||
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
|
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
|
||||||
workMode: workModeSchema.optional(),
|
workMode: workModeSchema.optional(),
|
||||||
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
||||||
|
storyGraphEnabled: z.boolean().optional(),
|
||||||
contextMetrics: conversationContextMetricsSchema.optional(),
|
contextMetrics: conversationContextMetricsSchema.optional(),
|
||||||
contextCompressionState:
|
contextCompressionState:
|
||||||
conversationContextCompressionStateSchema.optional(),
|
conversationContextCompressionStateSchema.optional(),
|
||||||
@@ -671,6 +672,11 @@ export const conversationSetPinnedSchema = z.object({
|
|||||||
pinned: z.boolean()
|
pinned: z.boolean()
|
||||||
}).strict()
|
}).strict()
|
||||||
|
|
||||||
|
export const conversationSetStoryGraphSchema = z.object({
|
||||||
|
conversationId: assistantIdSchema,
|
||||||
|
enabled: z.boolean()
|
||||||
|
}).strict()
|
||||||
|
|
||||||
export type ConversationSetPinnedInput = z.infer<
|
export type ConversationSetPinnedInput = z.infer<
|
||||||
typeof conversationSetPinnedSchema
|
typeof conversationSetPinnedSchema
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2100,6 +2100,7 @@ export type DesktopApi = {
|
|||||||
replace: (conversations: ConversationSnapshot[]) => Promise<void>
|
replace: (conversations: ConversationSnapshot[]) => Promise<void>
|
||||||
saveLocal: (batch: LocalConversationSaveBatch) => Promise<void>
|
saveLocal: (batch: LocalConversationSaveBatch) => Promise<void>
|
||||||
setPinned: (input: ConversationSetPinnedInput) => Promise<void>
|
setPinned: (input: ConversationSetPinnedInput) => Promise<void>
|
||||||
|
setStoryGraph: (input: { conversationId: string; enabled: boolean }) => Promise<void>
|
||||||
branchLocal: (
|
branchLocal: (
|
||||||
input: ConversationBranchInput
|
input: ConversationBranchInput
|
||||||
) => Promise<ConversationSnapshot>
|
) => Promise<ConversationSnapshot>
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export const ipcChannels = {
|
|||||||
conversationsReplace: 'conversations:replace',
|
conversationsReplace: 'conversations:replace',
|
||||||
conversationsSaveLocal: 'conversations:save-local',
|
conversationsSaveLocal: 'conversations:save-local',
|
||||||
conversationsSetPinned: 'conversations:set-pinned',
|
conversationsSetPinned: 'conversations:set-pinned',
|
||||||
|
conversationsSetStoryGraph: 'conversations:set-story-graph',
|
||||||
conversationsBranchLocal: 'conversations:branch-local',
|
conversationsBranchLocal: 'conversations:branch-local',
|
||||||
conversationsDeleteLocal: 'conversations:delete-local',
|
conversationsDeleteLocal: 'conversations:delete-local',
|
||||||
conversationsChanged: 'conversations:changed',
|
conversationsChanged: 'conversations:changed',
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ it('keeps menus and notifications interactive above real Electron modals', async
|
|||||||
}, 120000)
|
}, 120000)
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
['saves the conversation story graph switch from App through production IPC and revokes existing tools', 'story-graph-switch-main.ts'],
|
||||||
['opens the MCP editor from App settings with native browser isolation and real persistence', 'mcp-overlay-main.ts'],
|
['opens the MCP editor from App settings with native browser isolation and real persistence', 'mcp-overlay-main.ts'],
|
||||||
['keeps nested previews, sandbox keyboard, project modals and native overlays usable', 'overlay-paths-main.ts']
|
['keeps nested previews, sandbox keyboard, project modals and native overlays usable', 'overlay-paths-main.ts']
|
||||||
])('%s', async (_name, entry) => {
|
])('%s', async (_name, entry) => {
|
||||||
@@ -93,6 +94,9 @@ it.each([
|
|||||||
if (entry === 'mcp-overlay-main.ts') {
|
if (entry === 'mcp-overlay-main.ts') {
|
||||||
expect(result.persistedAfterReload).toBe(true)
|
expect(result.persistedAfterReload).toBe(true)
|
||||||
expect(result.scenarios).toHaveLength(4)
|
expect(result.scenarios).toHaveLength(4)
|
||||||
|
} else if (entry === 'story-graph-switch-main.ts') {
|
||||||
|
expect(result).toMatchObject({ persistedAfterReload: true, revokedExistingBinding: true, hiddenWhenDisabled: true })
|
||||||
|
expect(result.scenarios).toHaveLength(4)
|
||||||
} else {
|
} else {
|
||||||
expect(result).toMatchObject({ image: true, iframe: true, project: true, tooltip: true, citation: true })
|
expect(result).toMatchObject({ image: true, iframe: true, project: true, tooltip: true, citation: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { app, BrowserWindow, safeStorage } from 'electron'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { writeFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { registerIpcHandlers } from '../../src/main/ipc'
|
||||||
|
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
|
||||||
|
import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
|
||||||
|
import { CapabilityService } from '../../src/main/capabilities/capability-service'
|
||||||
|
import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store'
|
||||||
|
import { KnowledgeMcpGateway } from '../../src/main/agent/knowledge-mcp-gateway'
|
||||||
|
import type { KnowledgeService } from '../../src/main/knowledge/knowledge-service'
|
||||||
|
|
||||||
|
const directory = process.env.GB_MCP_DIRECTORY!
|
||||||
|
app.setPath('userData', join(directory, 'profile'))
|
||||||
|
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||||
|
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
const win = new BrowserWindow({ show: true, width: 1280, height: 900, useContentSize: true,
|
||||||
|
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false,
|
||||||
|
backgroundThrottling: false, preload: join(directory, 'preload.cjs') } })
|
||||||
|
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||||
|
database.initialize(directory)
|
||||||
|
const projectId = database.listProjects()[0]!.id
|
||||||
|
const conversationId = '00000000-0000-4000-8000-000000000321'
|
||||||
|
database.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Graph switch', updatedAt: 1 }, messages: [] }])
|
||||||
|
const application = new ApplicationSettingsStore(join(directory, 'application.json'))
|
||||||
|
await application.update({ heartbeatEnabled: true, checkUpdatesOnStartup: false })
|
||||||
|
const cipher = { isAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||||
|
encrypt: (value: string) => safeStorage.encryptString(value), decrypt: (value: Buffer) => safeStorage.decryptString(value) }
|
||||||
|
const capabilities = new CapabilityService(join(directory, 'capabilities.json'), join(directory, 'builtin-skills'), join(directory, 'imported-skills'), cipher)
|
||||||
|
const settings = new RuntimeSettingsStore(join(directory, 'runtime.json'), cipher, {})
|
||||||
|
const knowledge = { snapshot: () => ({ libraries: [{ id: '00000000-0000-4000-8000-000000000322', name: 'Knowledge', documentCount: 0 }], sources: [], documents: [], entities: [], relations: [], evidence: [], tasks: [] }),
|
||||||
|
database: { externalStore: { listBindings: () => [] } }, external: { listInstances: () => [] } } as unknown as KnowledgeService
|
||||||
|
const gateway = new KnowledgeMcpGateway(knowledge, { storyGraphService: {
|
||||||
|
available: async binding => (await application.get()).heartbeatEnabled === true && database.isConversationStoryGraphEnabled(binding.conversationId!),
|
||||||
|
read: database.readStoryGraph.bind(database)
|
||||||
|
} })
|
||||||
|
const token = gateway.grant('ui-probe', [], new AbortController().signal, 'none', undefined, undefined, undefined, undefined, undefined,
|
||||||
|
{ projectId, conversationId, runtimeTarget: 'model' })!
|
||||||
|
const binding = gateway.bindRemoteStoryGraph(token)!
|
||||||
|
const dispose = registerIpcHandlers(win,
|
||||||
|
{ capability: 'text', getStatus: async () => ({ available: true, name: 'Switch test', capability: 'text' }) } as never,
|
||||||
|
'CommandOrControl+Shift+Space', settings, capabilities,
|
||||||
|
{ clear() {}, cancelImport() {}, getDraft: () => [] } as never, knowledge,
|
||||||
|
database, { clear() {} } as never, {} as never, async () => {}, undefined, undefined,
|
||||||
|
undefined, undefined, application, undefined, undefined, undefined, undefined, undefined,
|
||||||
|
undefined, undefined, undefined, undefined, undefined, { getPending: async () => undefined } as never)
|
||||||
|
const js = <T = unknown>(code: string): Promise<T> => win.webContents.executeJavaScript(code)
|
||||||
|
win.webContents.on('console-message', event => { if (event.level === 'error') console.error(event.message) })
|
||||||
|
const wait = async (code: string): Promise<void> => {
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
if (await js(code)) return
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 25))
|
||||||
|
}
|
||||||
|
throw new Error(`Timeout: ${code}; ${await js('JSON.stringify({focus:document.activeElement?.tagName + " " + document.activeElement?.className,hidden:document.querySelector(".composer__options")?.hidden,text:document.body.innerText.slice(0,1000)})')}`)
|
||||||
|
}
|
||||||
|
const toggle = '.composer__options input[role="switch"]'
|
||||||
|
const open = async (): Promise<void> => {
|
||||||
|
await wait('!!document.querySelector(".composer__options-trigger")')
|
||||||
|
await wait(`!!document.querySelector('${toggle}')`)
|
||||||
|
await wait('document.querySelector(".topbar")?.textContent.includes("Graph switch")')
|
||||||
|
win.focus(); win.webContents.focus()
|
||||||
|
await wait('document.hasFocus()')
|
||||||
|
await js('document.querySelector(".composer__options-trigger").focus()')
|
||||||
|
const point = await js<{ x: number; y: number }>(`(() => { const r = document.querySelector('.composer__options-trigger').getBoundingClientRect(); return { x: Math.round(r.x+r.width/2), y: Math.round(r.y+r.height/2) }; })()`)
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
|
||||||
|
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
|
||||||
|
await wait(`document.activeElement === document.querySelector('${toggle}')`)
|
||||||
|
}
|
||||||
|
const scenarios: object[] = []
|
||||||
|
try {
|
||||||
|
await win.loadURL(process.env.GB_MCP_URL!)
|
||||||
|
await open()
|
||||||
|
assert.equal(await binding.available(), true)
|
||||||
|
for (const width of [1280, 390]) for (const theme of ['light', 'dark']) {
|
||||||
|
win.setContentSize(width, 900)
|
||||||
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
||||||
|
await wait(`innerWidth === ${width}`)
|
||||||
|
if (width < 900) await wait('!document.querySelector(".assistant-sidebar--open")')
|
||||||
|
await js('Promise.all(document.getAnimations().filter(a => a.effect.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {}))).then(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))')
|
||||||
|
if (await js('document.querySelector(".composer__options").hidden')) await open()
|
||||||
|
const layout = await js<{ fits: boolean; shared: boolean; beforeKnowledge: boolean }>(`(() => {
|
||||||
|
const e = document.querySelector('${toggle}'), r = e.getBoundingClientRect(), s = getComputedStyle(e);
|
||||||
|
const kb = document.querySelector('.knowledge-scope');
|
||||||
|
return { fits: r.width > 0 && r.left >= 0 && r.right <= innerWidth,
|
||||||
|
shared: s.appearance === 'none' && !!e.closest('.toggle-row'),
|
||||||
|
beforeKnowledge: !!(e.compareDocumentPosition(kb) & Node.DOCUMENT_POSITION_FOLLOWING) };
|
||||||
|
})()`)
|
||||||
|
assert.deepEqual(layout, { fits: true, shared: true, beforeKnowledge: true }, JSON.stringify({ width, theme,
|
||||||
|
rect: await js(`document.querySelector('${toggle}').getBoundingClientRect().toJSON()`) }))
|
||||||
|
scenarios.push({ width, theme, ...layout })
|
||||||
|
}
|
||||||
|
win.focus(); win.webContents.focus()
|
||||||
|
await js(`document.querySelector('${toggle}').focus()`)
|
||||||
|
await wait('document.hasFocus()')
|
||||||
|
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Space' })
|
||||||
|
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Space' })
|
||||||
|
await wait(`!document.querySelector('${toggle}').checked && !document.querySelector('${toggle}').disabled`)
|
||||||
|
assert.equal(database.isConversationStoryGraphEnabled(conversationId), false)
|
||||||
|
assert.equal(await binding.available(), false)
|
||||||
|
await assert.rejects(binding.call('story_graph_search', { query: 'Decision' }, new AbortController().signal), /story_graph_unavailable/)
|
||||||
|
await win.loadURL(process.env.GB_MCP_URL!)
|
||||||
|
await open()
|
||||||
|
assert.equal(await js(`document.querySelector('${toggle}').checked`), false)
|
||||||
|
await js('window.goodbuddy.updates.updateSettings({heartbeatEnabled:false})')
|
||||||
|
await wait(`!document.querySelector('${toggle}')`)
|
||||||
|
await js('window.goodbuddy.updates.updateSettings({heartbeatEnabled:true})')
|
||||||
|
await wait(`!!document.querySelector('${toggle}')`)
|
||||||
|
assert.equal(await js(`document.querySelector('${toggle}').checked`), false)
|
||||||
|
const modelAttempts = (globalThis as typeof globalThis & { overlayModelAttempts?: number }).overlayModelAttempts ?? 0
|
||||||
|
assert.equal(modelAttempts, 0)
|
||||||
|
await writeFile(join(directory, 'result.json'), JSON.stringify({ scenarios, modelAttempts,
|
||||||
|
persistedAfterReload: true, revokedExistingBinding: true, hiddenWhenDisabled: true }))
|
||||||
|
} finally { win.destroy(); await dispose(); await gateway.dispose(); database.close() }
|
||||||
|
app.exit(0)
|
||||||
|
}).catch(error => { console.error(error); app.exit(1) })
|
||||||
Reference in New Issue
Block a user