diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml
index 96a877ee..7ffeff70 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -121,6 +121,7 @@ jobs:
working-directory: packages/desktop
env:
DEEPAGENT_CODE_CHANNEL: ${{ github.event_name == 'push' && 'prod' || (github.event.inputs.channel || 'prod') }}
+ MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
NODE_OPTIONS: --max-old-space-size=4096
run: bun run build
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index cf05fff4..b38a5f02 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -94,6 +94,7 @@ jobs:
env:
DEEPAGENT_CODE_VERSION: ${{ needs.version.outputs.version }}
DEEPAGENT_CODE_RELEASE: ${{ needs.version.outputs.release }}
+ MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
@@ -326,6 +327,7 @@ jobs:
working-directory: packages/desktop
env:
DEEPAGENT_CODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
+ MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
diff --git a/packages/deepagent-code/docs/deepagent-v3-1-manual-test.md b/packages/deepagent-code/docs/deepagent-v3-1-manual-test.md
deleted file mode 100644
index 7c349b04..00000000
--- a/packages/deepagent-code/docs/deepagent-v3-1-manual-test.md
+++ /dev/null
@@ -1,242 +0,0 @@
-# DeepAgent V3.1 人工测试文档
-
-本文档面向**人工 UI 测试**。它列出在能运行 DeepAgent Code 应用的环境里需要手动验证的功能——这些功能依赖真实模型、真实编辑、真实 git 和浏览器 UI,自动化测试覆盖不到或只覆盖了后端。
-
-> 自动化部分(单测/typecheck)请看 §0 与旧文档 `deepagent-v3-manual-test.md`。本文件只讲 **V3.1 新增/改动**需要人工确认的东西。
->
-> 复跑时以当前终端输出和当前 git 工作树为准,不要沿用文中任何历史数字。
-
----
-
-## 0. 先跑自动化(确认基线干净)
-
-```bash
-# 仓库根:deepagent-code
-cd packages/llm && bun test && bun run typecheck
-cd ../deepagent-code && bun test test/deepagent/ && bun run typecheck
-cd ../app && bun run typecheck && bun test --preload ./happydom.ts ./src
-```
-
-验收:三包 typecheck 通过;llm / deepagent / app 测试全绿(`prompt.test.ts` 的 `glob tool keeps instance context` 偶发并发超时属已知 flaky,隔离重跑通过,可忽略)。
-
----
-
-## 0.5 如何启动应用进行测试
-
-> 要求:Bun 1.3+。所有命令在**仓库根目录** `deepagent-code/` 执行。本文档大部分功能(情景 toggle、wish 审阅、设置页强度、reviewer 页)都需要**浏览器 Web UI**,所以推荐"服务端 + Web 应用"两段式启动。
-
-**第一步:安装依赖(仅首次)**
-
-```bash
-bun install
-```
-
-**方式 A(推荐,一条命令起服务端 + 打开 Web UI)**
-
-```bash
-bun dev web
-```
-
-这会启动 DeepAgent Code 服务端并打开 Web 界面。
-
-**方式 B(分两步,便于看服务端日志 / 调 UI 热更新)**
-
-```bash
-# 终端 1:起 headless API 服务端(默认端口 4096)
-bun dev serve
-# 端口被占用可改: bun dev serve --port 8080
-
-# 终端 2:起 Web 应用(Vite,默认 http://localhost:5173)
-bun run --cwd packages/app dev
-```
-
-然后浏览器打开终端 2 输出的地址(通常 `http://localhost:5173`)。**服务端必须一直开着**,否则 UI 没有后端功能。
-
-**指定工作目录**:默认在 `packages/deepagent-code` 目录下跑;要对某个具体仓库/目录测试:
-
-```bash
-bun dev web <目标目录的绝对路径>
-# 或对本仓库根自身: bun dev web .
-```
-
-**进入会话**:在 UI 里打开一个工作目录 → 新建/进入会话 → 在右下角输入框测试本文档各项。
-
-**配置 provider / 模型**:首次需在设置页连接一个上游 provider(OpenAI / DeepSeek / Anthropic)并填 API key,否则真实模型相关项(wish 补全、多轮、知识)无法跑。
-
-**切换 agent 强度**:设置页(general / high / max / ultra)。**切换情景模式**:输入框发送键左侧的 toggle(direct / wish)。
-
-> 提示:很多项依赖真实模型调用,请确保 provider 已连且网络可用;否则 wish 会走启发式回退(见 §三 2.6)。
-
----
-
-## 一、最重要:四件事如何配合(V3.1 核心心智模型)
-
-V3.1 把"对用户可见的控制"收敛成**两个**:
-
-- **情景模式**(发送键左侧的 toggle):`direct`(你自己写 prompt)/ `wish`(DeepAgent 帮你准备 prompt 并在后续给下一轮建议)。**每轮可切**。
-- **agent 强度**(设置页):`general → high → max → ultra`,单调递增,每档只多加一件事。
-
-另外两件事**不是用户开关**,由强度自动推导:
-- **多轮 workflow**(微轮自修复 / 宏轮提下一目标)——`high` 及以上自动开。
-- **知识检索**——`max`、`ultra` 才开。
-
-测试时请始终带着这张表对照:
-
-| 强度 | 比上一档多了什么 | 知识 | 自动多轮 | 宏轮谁批准 |
-|---|---|---|---|---|
-| general | —(最轻,单轮,行为≈原版 opencode) | 关 | 无 | —(用户自己发下一条) |
-| high | 控制面 artifacts + 自动微轮自修复 | 关 | 有 | 人点"继续" |
-| max | + durable 知识检索 | 开 | 有 | 人点"继续" |
-| ultra | + 自治(监督线程自动推进宏轮) | 开 | 有 | **监督线程自动** |
-
----
-
-## 二、情景模式 toggle(D1)
-
-**目的**:验证发送键左侧的情景 toggle 存在、可切换、且真的改变这一轮的提交行为。
-
-| 步骤 | 操作 | 期望 |
-|---|---|---|
-| 1.1 | 打开一个工作目录,看输入框右下角发送键**左边** | 有一个情景 toggle 图标(铅笔=direct / 对话气泡=wish),hover 有中文提示"情景模式:直接/许愿 …" |
-| 1.2 | 点击 toggle | 在 `direct` ↔ `wish` 间切换;图标和 tooltip 跟着变 |
-| 1.3 | 切到 `direct`,输入一句话发送 | 直接发送,不弹任何"准备 prompt"的中间步骤 |
-| 1.4 | 切到 `wish`,输入一句话发送 | 进入 wish 流程(见 §三);不是直发 |
-| 1.5 | **新建会话**(还没发第一条)就先切 toggle,再发第一条 | 第一条就按你切的模式走(toggle 在会话创建前设置也生效) |
-
-验收:toggle 可见、可切、每轮生效,且新会话首轮也认你的选择。
-
----
-
-## 三、wish 第一轮:AI 补全 prompt + 人审(A2 + D2)
-
-**目的**:验证 wish 第一轮**真的调模型**把粗略需求补成完整可执行 prompt,把 AI 的推断**显式列出**让你审阅,且**先回到你面前、你确认后才发**。
-
-> 前提:情景模式 = `wish`(§二)。强度 high 或以上更能看出区别。
-
-| 步骤 | 操作 | 期望 |
-|---|---|---|
-| 2.1 | 输入一句**粗略**需求(例:"给项目加个登录"),点发送 | 输入框/会话进入"意见生成中"忙碌态(不是卡死),稍候出现准备好的 prompt 供审阅 |
-| 2.2 | 看准备好的 prompt 内容 | 是一段**完整、可直接执行**的 prompt(补全了缺口),不是把你原话原样塞回去 |
-| 2.3 | 看 prompt 里的"假设/Assumptions"部分 | AI 替你补的每个推断(例:"用 JWT"、"加到现有 /auth 路由")都**显式列出**,可以逐条看到 |
-| 2.4 | 修改其中一条不对的假设/目标,再确认发送 | 你改的内容进入正式任务,不是原始那版 |
-| 2.5 | 在审阅界面点取消 | 不提交任务,回到可编辑状态 |
-| 2.6 | (故障注入,可选)断网或让模型不可用,再发 wish | 不应崩溃;回退到启发式准备的 prompt,仍可审阅(fail-soft) |
-
-验收:wish 第一轮产出的是模型补全的完整 prompt + 显式假设;人审/可改/确认后才提交;模型不可用时优雅降级。
-
----
-
-## 四、agent 强度分层(C4 / 知识门)
-
-**目的**:验证四档强度行为正确分层,尤其 `general` 不拖累基线、知识只在 max/ultra 开。
-
-> run 目录在 `~/.deepagent/code/runs/`(或配置的 runs 目录),每个 run 一个文件夹。
-
-| 步骤 | 操作 | 期望 |
-|---|---|---|
-| 3.1 | 设置页选 `general`,跑一个普通编码请求 | 行为≈原版 opencode;**不产生**控制面 artifacts;run 目录不长东西 |
-| 3.2 | 切 `high` 跑同一类请求 | 系统提示头部出现 DeepAgent 标识;run 目录产生控制面 artifacts;**不注入 durable 知识** |
-| 3.3 | 切 `max` 跑一个 GPU/CUDA 类请求 | `KNOWLEDGE_RETRIEVAL_RESULT.json` 出现策略/方法 refs,带 `evidence_strength`、`do_not_use_refs`/`gap_analysis` |
-| 3.4 | 设置页确认有 `ultra` 选项可选 | 下拉里能看到 `general/high/max/ultra` 四档 |
-
-验收:四档可选;general 纯透传零 artifacts;知识只在 max/ultra 出现。
-
----
-
-## 五、自动多轮:微轮 vs 宏轮(A3)
-
-**目的**:验证 high 以上的两层"轮"——微轮自动自修复(对人不可见)、宏轮提下一目标(需批准)。
-
-> 多轮默认开启;若环境设了 `DEEPAGENT_MULTIROUND=0` 会退回单轮(fail-closed 调试开关)。
-
-| 步骤 | 操作 | 期望 |
-|---|---|---|
-| 4.1 | `high` 跑一个**带验证命令**的任务(仓库有 typecheck/test),故意让首次产出验证失败 | 系统**自动**跑验证→失败→诊断→再修一轮(微轮,你视角上还是"一轮内");最多若干轮;失败回滚到干净/最佳状态 |
-| 4.2 | 当前目标收敛后 | 产生一条"下一轮建议"(`{status, body}`),`status=continue` 时等你点"继续"(high/max 需人批准) |
-| 4.3 | 观察 git 工作区 | 失败轮不把工作区留在破损状态 |
-| 4.4 | 故意让模型自报"测试通过"但实际验证失败 | 系统以 **runner 实测**为准,识别出"声明与实测不一致",不会误判完成(status 倾向 needs_human) |
-
-验收:微轮自动修复;宏轮建议由**客观实测**决定而非模型自报;high/max 的宏轮等人批准。
-
----
-
-## 六、ultra 自治(监督线程)
-
-**目的**:验证 ultra 在 wish + 多轮下能**无人值守自动推进**宏轮直到收敛,并在该停时升级给人。
-
-> ultra 依赖 wish 建议机制,请把情景模式设为 `wish`。
-
-| 步骤 | 操作 | 期望 |
-|---|---|---|
-| 5.1 | 强度 `ultra` + 情景 `wish`,给一个需要多步的任务 | 自动跑:执行→验证→(收敛则)自动用 wish 建议播种下一宏轮→再执行……无需你每轮点继续 |
-| 5.2 | 观察停止条件 | 收敛(done)、反复无进展、预算上限、或歧义(needs_human)时停下;不会无限烧 |
-| 5.3 | 与 max 对比 | 同样任务在 `max` 下,每个宏轮会**停下等你点继续**;`ultra` 不停 |
-
-验收:ultra 自动推进宏轮并能正确收敛/升级;max 仍需人批准——两者差别就在"谁批准宏轮"。
-
----
-
-## 七、停止 → 回到 direct(D3,fail-safe)
-
-**目的**:验证任何"停止"都把情景重置为 `direct` 并暂停自动化,直到你下一条消息。
-
-| 步骤 | 操作 | 期望 |
-|---|---|---|
-| 6.1 | 在 `wish`(或 ultra 自动跑)过程中点停止 | 任务停下;情景 toggle 回到 `direct` |
-| 6.2 | 停止后直接再发一条消息 | 这一条按 `direct` 直发,不再自动进入 wish/多轮,直到你重新切回 wish |
-
-验收:停止 = 回到最简单可控的 direct 模式 + 暂停自动化。
-
----
-
-## 八、Reviewer 复盘页 + 人审晋升(F4 / A7)
-
-**目的**:验证能从 run artifacts 复盘"为何接受/回滚",并能人审晋升知识。
-
-| 步骤 | 操作 | 期望 |
-|---|---|---|
-| 7.1 | 跑过几个 high/max run 后,浏览器访问 `/
/review` | 左侧列出最近 run id |
-| 7.2 | 点选一个 run | 右侧显示状态、候选谱系、诊断、accept/rollback 理由、可展开 RUN_CONTEXT |
-| 7.3 | 对一个 staged 知识候选点"晋升/promote" | 该候选被加上 `promoted_at`,之后 max/ultra 任务可检索到它 |
-| 7.4 | 对一个候选点"拒绝/reject" | 进入拒绝缓冲,不再被重新学习 |
-| 7.5 | 反污染确认:未晋升的候选 | **不**出现在 `KNOWLEDGE_RETRIEVAL_RESULT.json` 的 selected_refs(只有人审晋升的才注入) |
-
-验收:复盘页能投影决策理由;promote/reject 可用;未晋升候选永不被检索注入。
-
----
-
-## 九、后台学习不拖累主流程(E1)
-
-**目的**:验证学习在主任务线程**之外**跑,不阻塞当轮。
-
-| 步骤 | 操作 | 期望 |
-|---|---|---|
-| 8.1 | 连续完成几个 high/max 任务 | 每轮结束很快返回,不因"学习"卡顿;学习在后台异步发生 |
-| 8.2 | 完成后检查项目记忆 | 安全的项目本地记忆自动并入 project memory;需审的(策略/反模式/敏感)进 memory inbox 等人审 |
-
-验收:学习异步发生、不阻塞当轮;安全候选自动并入、需审候选进 inbox。
-
----
-
-## 十、回归确认(务必)
-
-| 步骤 | 操作 | 期望 |
-|---|---|---|
-| 9.1 | `general` + `direct` 正常用一段时间 | 与原版 opencode 完全一致,无任何 DeepAgent 干预,无多余 artifacts |
-| 9.2 | 切到任意上游 provider(OpenAI/DeepSeek/Anthropic)在 high 下跑 | DeepAgent 对**所有 provider** 生效(控制面 artifacts 正常产出);provider 的 auth/streaming/工具/MCP/审批语义不变 |
-| 9.3 | 触发内部 kill switch(如设置)| 命中 DeepAgent 的请求 **fail-closed**(明确报错),不是静默旁路 |
-
-验收:general 不拖累基线;DeepAgent 全局生效但不破坏 provider 既有能力;kill switch 真正 fail-closed。
-
----
-
-## 附:本轮(V3.1)重点改动速查
-
-- 情景模式收敛为 `direct`/`wish`(移除 `design`);发送键左侧 toggle,wish 默认。
-- agent 强度新增 `ultra`(= max + 自治监督线程)。
-- wish 第一轮真调模型补全 prompt + 显式 assumptions + 人审后才发。
-- 多轮拆成"微轮(自动自修复)/ 宏轮(提下一目标,需批准;ultra 自动批准)"。
-- round_report 用 **runner 实测**对账模型声明,收敛与否客观判定。
-- 全局运行时:激活按**强度**而非 provider,DeepAgent 对所有 provider 生效。
-- 后台学习 worker 队列化,移出主任务线程。
-- kill switch 改为真正 fail-closed。
diff --git a/packages/deepagent-code/docs/deepagent-v3-manual-test.md b/packages/deepagent-code/docs/deepagent-v3-manual-test.md
deleted file mode 100644
index 141a4786..00000000
--- a/packages/deepagent-code/docs/deepagent-v3-manual-test.md
+++ /dev/null
@@ -1,240 +0,0 @@
-# DeepAgent V3 人工验证测试文档
-
-本文档面向**人工验证**。它列出需要在能运行 DeepAgent Code 应用的环境里手动验证的项目,因为这些项目依赖真实模型、真实编辑、真实 git 和浏览器 UI。
-
-本文档中的测试数字只记录最近一次历史验证快照,不再作为当前通过结论。复跑时以当前终端输出和当前 git 工作树为准;如果输出不同,更新本文件或另写新的验证记录,不要沿用旧数字。
-
-仓库根:`third-party module/deepagent-code`。运行时基于 **bun**。
-
----
-
-## 0. 自动化验证命令(复跑确认)
-
-这些是 DeepAgent V3 相关的单元/集成测试和类型检查入口。执行时以当前输出为准:
-
-```bash
-cd "third-party module/deepagent-code/packages/llm"
-bun test
-bun run typecheck
-
-cd "../deepagent-code"
-bun test test/deepagent/
-bun run typecheck
-
-cd "../app"
-bun run typecheck
-```
-
-最近一次历史快照(仅供对照,不是当前事实):
-
-| 包 | 命令 | 历史结果 |
-| ------------------------- | -------------------------- | ----------------------------- |
-| `packages/llm` | `bun test` | 343 pass / 0 fail(39 files) |
-| `packages/llm` | `bun run typecheck` | 通过 |
-| `packages/deepagent-code` | `bun test test/deepagent/` | 29 pass / 0 fail |
-| `packages/deepagent-code` | `bun run typecheck` | 通过 |
-| `packages/app` | `bun run typecheck` | 通过 |
-
-覆盖的 V3 能力(自动化已证):
-
-- 防误导知识检索(强制 top-k、证据强度门、do_not_use、advisory)、**仅 promoted_at 可检索(F2 反污染)**
-- 回归门 ablation、知识快照 ship-gate
-- 多轮 loop 决策逻辑(diagnosis-before-retry、rollback-to-best、ensureSession F3)
-- 文档图 DocumentStore(不变量/版本/双向链接)、run-graph、reviewer 投影
-- 学习晋升门 R1/R2/R4 + rejected buffer + persistPromoted→可检索
-- 领域包激活(GPU)、RUN_CONTEXT 工作记忆、prompt advisory、shell validation 执行器
-
-> 历史注记:当时 deepagent-code **全量** `bun test` 有少量失败,审计判断与 DeepAgent V3 改动无关。该列表不是当前失败清单,复跑后必须重新判断:
->
-> - TUI `attention.test.ts`:全量并发下 flaky(隔离跑 18/0 全绿)。
-> - `session.llm-native.request` ×2:测试 `session/llm/request.ts`——该文件在本次会话**之前就已是未提交改动**(`git status` 为 M,非本人所改)。
-> - `help-snapshots`:spawn CLI 二进制,环境性失败(F1–F5 之前就在失败)。
-> - `httpapi-file > serves search endpoints`:文件搜索端点(独立 group,环境性)。
->
-> 历史判据:当时 `git status` 可见工作树在该会话前已有大量未提交改动(request.ts / native-\* / provider.ts / sdk / core / 多个 app 文件);该会话改动限于 deepagent/prompt.ts/server-deepagent/review.tsx,且定向测试通过。当前复跑时不要复用这个判据,必须重新看工作树和失败栈。
-
----
-
-## 1. 前置:构建与启动 DeepAgent Code
-
-```bash
-cd "third-party module/deepagent-code"
-bun install
-# 按仓库 README 启动桌面/web 应用(app + server)。确认能打开 UI、能选 provider。
-```
-
-验收:应用能启动,能进入某个工作目录会话。
-
----
-
-## 2. 模式语义:general / high / max(核心设计)
-
-**目的**:验证 V3.1 的三级语义。`general` 是轻量 DeepAgent,`high` 是控制面增强,`max` 在 `high` 的基础上再开启 durable 知识检索。
-
-| 步骤 | 操作 | 期望 |
-| ---- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 2.1 | 用 `agent.mode=general` 跑一个普通编码请求 | 走轻量 DeepAgent 路径:保留最小 runtime/audit 记录,不启用 durable 知识或多轮修复;不产生 `KNOWLEDGE_RETRIEVAL_RESULT.json` 这类知识产物 |
-| 2.2 | 切 `high` 跑同一请求 | 系统提示头部出现 DeepAgent Code high-mode 语义;run 目录产生控制面 artifacts;**不注入任何 durable 知识**(`KNOWLEDGE_RETRIEVAL_RESULT.json` 的 `enabled=false` 或无策略注入) |
-| 2.3 | 切 `max` 跑一个 GPU/CUDA 相关请求(如"优化这个 sgemm kernel") | `KNOWLEDGE_RETRIEVAL_RESULT.json` 出现策略/方法 refs,且带 `evidence_strength`、`do_not_use_refs`/`gap_analysis` 字段;`retrieval_policy.topk_by_kind` 存在;在多轮开关开启时可继续进入验证→诊断→修订 |
-
-**怎么看**:DeepAgent run 目录在 `~/.deepagent/code/runs/`(或仓库配置的 runs 目录)下,每个 run 一个文件夹。
-
-验收:三模式行为分层正确;`general` 轻量、`high` 控制面增强、`max` 再加知识检索;知识全部以"可选提示 + 证据强度 + 来源"呈现,无祈使步骤。
-
-### 2.4 推荐组合
-
-V3.1 把四类决策分开验证:情景模式(`direct/wish/design`)负责执行前准备,agent 强度(`general/high/max`)负责运行时深度,多轮 workflow 是执行策略,知识系统由 agent 强度和知识策略控制。
-
-| 场景 | 推荐组合 | 说明 |
-| ----------------- | ------------------------------------- | ------------------------------------------------------ |
-| 普通快速任务 | `general` + `direct` 或 `wish` + 单轮 | 目标是低干预,允许最小审计,但不启动知识或多轮修复 |
-| 需要先审阅再提交 | `wish` + `general` 或 `wish` + `high` | 先出草稿再确认;执行层是否要控制面增强,再看任务复杂度 |
-| 结构化方案/大改动 | `design` + `high` | 先出草稿和风险/验收说明,再跑控制面增强的单轮任务 |
-| 疑难任务 | `design` + `max` | 先结构化确认,再加知识检索;是否进入多轮由独立开关决定 |
-
-验收:模型可以建议是否继续下一轮,但不能单独授权自动多轮;runner 必须根据开关、模式、验证结果、预算、轮次数和失败类型做最终裁决。
-
----
-
-## 3. 反污染(F2,关键原则)
-
-**目的**:验证未经人审的自学习候选**不会**被检索注入。
-
-| 步骤 | 操作 | 期望 |
-| ---- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
-| 3.1 | 用 max 模式连续完成几个任务(产生 learning 候选) | runs 中出现 `LEARNING_WRITEBACK_MANIFEST.json`,候选 `promotion_decision` 为 staged/needs_review |
-| 3.2 | 检查 memory 存储(`~/.deepagent/code/memory/.../memories.jsonl`、`strategies.jsonl`) | 候选条目存在,但**无 `promoted_at` 字段** |
-| 3.3 | 再跑一个 max 任务,检查 `KNOWLEDGE_RETRIEVAL_RESULT.json` | 上述未晋升候选**不出现**在 `selected_refs`(只有 CORE/领域包 + 已人审晋升的才注入) |
-
-验收:staged(无 `promoted_at`)候选永不被检索注入——反污染门生效。
-(晋升路径见 §7。)
-
----
-
-## 4. 多轮自治 loop(A6 / F3,默认关闭)
-
-**目的**:验证多轮 loop 真实驱动。它是执行策略开关,不是模式本身;默认 OFF,需显式开启。
-
-> ⚠️ 这是动 session 主循环的功能,默认 `DEEPAGENT_MULTIROUND` 未设 → 行为与单轮一致(这是有意的 fail-closed 设计)。`general` 不进入多轮;只有 `high/max` 在开启后才允许进入验证→诊断→修订回路。
-
-| 步骤 | 操作 | 期望 |
-| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
-| 4.1 | **不设**环境变量,max 模式跑任务 | 单轮完成,行为与之前一致(回归确认:开关关闭零影响) |
-| 4.2 | 设 `DEEPAGENT_MULTIROUND=1` 重启,max 模式跑一个**带验证命令**的任务(仓库有 `package.json` typecheck/test,或 `AGENTS.md` 声明验证命令),故意让首次产出验证失败 | 系统**自动**跑验证命令 → 失败 → 注入一条诊断 follow-up → 模型再修一轮;最多 5 轮;全部失败则停止并保留最佳/干净状态 |
-| 4.3 | 观察工作区 git 状态 | 失败轮不应把工作区留在破损状态(回滚到 best/clean) |
-
-验收:开启后 `high/max` 的多轮 loop 真实运行(验证→诊断→修订→回滚);关闭时零影响,`general` 始终保持单轮。
-(注:A5 真实 git checkpoint 已接入 live 路径——`prompt.ts` 经 `Snapshot.defaultLayer` 提供真实 `Snapshot.Service`,回滚为真实 git 级还原。见 §8。)
-
-### 4.4 许愿/设计模式与多轮的配合
-
-| 步骤 | 操作 | 期望 |
-| ----- | ---------------------------------------- | ---------------------------------------------------------------------------------------- |
-| 4.4.1 | 用 `wish + high` 提交任务并确认草稿 | 首轮执行使用确认后的 task prompt,后续多轮不得重新弹出 wish 确认 |
-| 4.4.2 | 用 `design + max` 提交复杂任务,开启多轮 | design 草稿、context plan、知识快照在确认后锁定;多轮诊断 follow-up 只围绕已确认目标修订 |
-| 4.4.3 | 故意制造范围不清或风险越界 | runner 应停止并要求用户确认,而不是让模型自行扩大任务范围 |
-
-验收:情景模式只负责执行前准备;多轮 workflow 负责执行后的验证、诊断和修订。除非检测到范围变化、缺少用户决策或安全歧义,否则多轮不重新进入许愿/设计确认流。
-
----
-
-## 4.5 AgentCode 软件经验提取验收
-
-**目的**:验证 V3.1 吸收的 AgentCode 软件经验已经体现到产品行为中。
-
-| 经验 | 验收点 |
-| -------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
-| 执行前准备 | `wish/design` 先生成草稿、上下文计划、假设和验收条件,确认后才进入主任务线程 |
-| 复用底层执行系统 | 工具、MCP、approval、provider auth、streaming、session 仍由继承 runtime 执行,DeepAgent 只加规划、审计、验证、复盘和学习控制面 |
-| 用户目标不可静默改写 | Prompt refinement 只能结构化和澄清;不得悄悄改变用户核心目标 |
-| 显式状态而非隐藏流程 | run state、router audit、work package、validation、checkpoint、learning、review artifacts 可在 run 目录和 review 页面检查 |
-| 自主性有边界 | 多轮只有在策略允许、验证失败明确、预算允许、轮次未超限且可保持工作区可解释时继续 |
-| 知识受治理 | 自学习候选先 staged/needs_review;只有通过人审晋升的知识可被 `max` 检索 |
-| 失败 fail-closed | disabled runtime、kill switch、provider-executed tool、checkpoint mismatch、cancel、预算耗尽等必须显式记录,不能静默走无审计路径 |
-
----
-
-## 5. 文档图工作记忆(F5)
-
-**目的**:验证每个 run 物化成文档图。
-
-| 步骤 | 操作 | 期望 |
-| ---- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
-| 5.1 | 任意 high/max 跑一个 run 后,进入该 run 目录的 `graph/` 子目录 | 存在 `docs//*.json`:`run_state`、`run_context`、`candidate`、`decision`(失败时还有 `diagnosis`),文件名含版本 `@v1` |
-| 5.2 | 打开 `decision` 文档 | `links` 含 `refines`→candidate;失败 run 的 decision 含 `triggered_by`→diagnosis |
-| 5.3 | 打开 candidate 文档 | 有内容寻址 `hash`(`sha256:...`)、`provenance` |
-
-验收:文档图按 docs/28 结构落盘(双向链接、版本、内容寻址)。
-
----
-
-## 6. Reviewer UI(F4 / A7)
-
-**目的**:验证复盘页面能读 run review。
-
-| 步骤 | 操作 | 期望 |
-| ---- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
-| 6.1 | 跑过几个 DeepAgent run 后,浏览器访问 `//review` 路由 | 左侧列出最近的 run id |
-| 6.2 | 点选一个 run | 右侧显示:状态、候选谱系、诊断(若有)、"为何 accept/rollback" 的 decision 理由、可展开的 RUN_CONTEXT |
-| 6.3 | 直接调服务端点验证数据层 | `GET /deepagent/reviews`(带应用所需的 workspace 路由参数与鉴权)返回 `{reviews:[...]}`,每个含 candidates/diagnosis |
-
-验收:reviewer 页面能由 run artifacts 投影出"为何接受/回滚"。
-(注:页面已改用 SDK client `client.deepagent.reviews({ directory })` 取数,带应用的 workspace 路由参数,不再是 raw fetch。见 §8。)
-
----
-
-## 7. 学习晋升门(人审 → durable,§3 的另一半)
-
-**目的**:验证人审晋升后知识才可检索。
-
-> review 页已接入 promote/reject 端点与按钮(`client.deepagent.knowledge.promote/reject`),人审晋升已有 UI;仍可用代码/脚本路径做受控晋升。
-
-| 步骤 | 操作 | 期望 |
-| ---- | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
-| 7.1 | 用代码/脚本调用晋升:对某个 staged 候选执行 `DeepAgentMemoryStore.promote(candidateId)`(或 `DeepAgentPromotion.persistPromoted(record)`) | 该候选 `memories/strategies.jsonl` 条目被加上 `promoted_at` |
-| 7.2 | 再跑 max 任务 | 该已晋升知识现在**可**出现在 `selected_refs`(前提:过相关度+证据门) |
-| 7.3 | 尝试晋升一个 `scope=sealed`(hidden)来源的候选 | `promote(...)` 抛 R1 错误,拒绝晋升 |
-
-验收:人审晋升后知识可用;sealed 永不可晋升(R1)。
-
----
-
-## 8. 已知限制(设计层面、需你决策是否继续)
-
-这些不是 bug,是当前实现的**边界**,列出供你知情。
-
-> 历史更新(V3.1 复审):原 §8 列的前三条已在后续提交修复,不再是限制,保留为历史记录:
->
-> - ~~A5 git checkpoint 未接 live~~:`prompt.ts` 现已注入真实 `Snapshot.Service`,多轮 loop 的 `track/restore` 接到真实 git 实现(`src/snapshot/index.ts`)。
-> - ~~Reviewer 页面 raw fetch~~:review 页已改用 SDK client(`client.deepagent.reviews()`),并新增 promote/reject 端点与 UI,人审晋升不再只是离线脚本。
-> - ~~多轮 loop 默认 OFF~~:`multiRoundEnabled()` 现默认 ON(`DEEPAGENT_MULTIROUND` 仅在显式设为 `0`/`false` 时关闭)。
-
-当前仍存在的边界:
-
-1. **文档图是并行物化**:gateway 仍写扁平 artifacts(兼容现有),同时额外物化 `graph/`;尚未让文档图成为唯一真相源。
-2. **V3.1 设计尚未实现**:scenario 模式 `design` 已移除;`wish` 第一轮仍是启发式 stub(未接模型);结构化 round report、宏轮/wish 建议、`ultra` 强度、全局运行时(按强度而非 provider 激活)都还在 worklist 上(见 `docs/deepagent-v3-1-worklist.md`)。复跑本文件时以当前代码为准。
-
----
-
-## 9. 回归确认(务必)
-
-| 步骤 | 操作 | 期望 |
-| ---- | ---------------------------------------------- | -------------------------------------------------------------------------------- |
-| 9.1 | general 模式正常使用一段时间 | 保持轻量 DeepAgent 行为:最小 runtime/audit 记录正常,知识检索和多轮修复都不进入 |
-| 9.2 | 不设 `DEEPAGENT_MULTIROUND`,high/max 正常使用 | 单轮行为正常,控制面 artifacts 正常产出,知识按模式生效,无卡死/异常 |
-
-验收:DeepAgent 的所有新功能要么默认不改变既有行为(`general` 轻量、`high/max` 多轮关闭),要么只在对应模式下增强——**绝不拖累基线**。
-
----
-
-## 附:本轮审计修复对照(F1–F5)
-
-下表是历史修复快照,用于说明当时修了什么;自动化验证列不是当前测试状态。
-
-| 项 | 修复 | 自动化验证 |
-| ---------------- | ------------------------------------------------------------------------ | ------------------------------------------- |
-| F1 死代码 | 删除被取代的 `loop-driver.ts` | llm 343 pass(39 files) |
-| F2 反污染 | 检索只读 `promoted_at` 已晋升条目 | llm 测试绿(promotion 可检索、staged 不可) |
-| F3 多轮 session | `maybeRunRounds` 自己 `ensureSession`,不依赖被 gateway prune 的 session | deepagent-code deepagent 29 pass |
-| F4 reviewer 死链 | 加 `GET /deepagent/reviews` 端点 + 页面对齐 | deepagent-code/app typecheck 干净 |
-| F5 文档图未接线 | gateway run 完成时 best-effort 物化文档图到 `graph/` | llm typecheck 干净、测试绿 |
diff --git a/packages/deepagent-code/script/build-node.ts b/packages/deepagent-code/script/build-node.ts
index fe65391e..e9bdb73f 100755
--- a/packages/deepagent-code/script/build-node.ts
+++ b/packages/deepagent-code/script/build-node.ts
@@ -28,6 +28,16 @@ const result = await Bun.build({
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build the Node server")
+await Bun.file("./dist/node/models-dev.build.json").write(
+ JSON.stringify(
+ {
+ source: generated.modelsSource,
+ sha256: generated.modelsSha256,
+ },
+ null,
+ 2,
+ ),
+)
// Bun preserves CommonJS __dirname/__filename values for bundled dependencies. Those values point
// at the build machine and are unusable after installation, so make the bundle reproducible and
diff --git a/packages/deepagent-code/script/build.ts b/packages/deepagent-code/script/build.ts
index e2dddcc4..38db8931 100755
--- a/packages/deepagent-code/script/build.ts
+++ b/packages/deepagent-code/script/build.ts
@@ -253,6 +253,10 @@ for (const item of targets) {
...(sourceCommit ? { sourceCommit } : {}),
...(sourceDirty !== undefined ? { sourceDirty } : {}),
binarySha256,
+ modelsDev: {
+ source: generated.modelsSource,
+ sha256: generated.modelsSha256,
+ },
},
},
null,
diff --git a/packages/deepagent-code/script/generate.ts b/packages/deepagent-code/script/generate.ts
index 6df05026..5561b35a 100644
--- a/packages/deepagent-code/script/generate.ts
+++ b/packages/deepagent-code/script/generate.ts
@@ -5,4 +5,11 @@ process.chdir(path.resolve(import.meta.dir, ".."))
const models = await loadModelsData()
export const modelsData = models.data
-console.log(`Loaded models.dev snapshot from ${models.source}`)
+export const modelsSource = (() => {
+ if (/^https?:\/\//.test(models.source)) return models.source
+ const relative = path.relative(path.resolve(import.meta.dir, ".."), path.resolve(models.source))
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) return relative.replaceAll("\\", "/")
+ return `external:${path.basename(models.source)}`
+})()
+export const modelsSha256 = models.sha256
+console.log(`Loaded models.dev snapshot from ${models.source} (sha256:${models.sha256})`)
diff --git a/packages/deepagent-code/script/models-data.ts b/packages/deepagent-code/script/models-data.ts
index a4cb245d..9fe3f0db 100644
--- a/packages/deepagent-code/script/models-data.ts
+++ b/packages/deepagent-code/script/models-data.ts
@@ -1,7 +1,5 @@
import { mkdir, rename, rm } from "node:fs/promises"
-import os from "node:os"
import path from "node:path"
-import { resolveDataPath } from "@deepagent-code/core/global-path"
const repositorySnapshotFile = path.resolve(import.meta.dir, "../test/tool/fixtures/models-api.json")
@@ -18,7 +16,7 @@ export async function loadModelsData(
if (configuredFile) {
const configured = await readCatalog(configuredFile)
if (!configured) throw new Error(`Configured models.dev snapshot is invalid: ${configuredFile}`)
- return { data: JSON.stringify(configured), source: configuredFile }
+ return result(configured, configuredFile)
}
const modelsURL = (environment.DEEPAGENT_CODE_MODELS_URL?.trim() || "https://models.dev").replace(/\/$/, "")
@@ -27,26 +25,34 @@ export async function loadModelsData(
})
.then(async (response) => (response.ok ? catalog(await response.json()) : undefined))
.catch(() => undefined)
- const cacheFile = options.cacheFile ?? path.join(resolveDataPath(), "cache", "models.json")
if (remote) {
- await persistCatalog(cacheFile, remote).catch((error) =>
- console.warn(
- `Unable to update models.dev build cache: ${error instanceof Error ? error.message : String(error)}`,
- ),
- )
- return { data: JSON.stringify(remote), source: `${modelsURL}/api.json` }
+ if (options.cacheFile) {
+ await persistCatalog(options.cacheFile, remote).catch((error) =>
+ console.warn(
+ `Unable to update models.dev build cache: ${error instanceof Error ? error.message : String(error)}`,
+ ),
+ )
+ }
+ return result(remote, `${modelsURL}/api.json`)
}
- const fallbacks = options.fallbackFiles ?? [
- cacheFile,
- path.join(os.homedir(), ".cache", "opencode", "models.json"),
- repositorySnapshotFile,
- ]
+ // Build inputs must not depend on the builder's DeepAgent/OpenCode runtime caches. A caller may
+ // pass explicit fallback files for tests or controlled builds; the default is the committed copy.
+ const fallbacks = options.fallbackFiles ?? [repositorySnapshotFile]
const cached = (await Promise.all(fallbacks.map(async (file) => ({ file, data: await readCatalog(file) })))).find(
(item): item is { file: string; data: Record } => item.data !== undefined,
)
if (!cached) throw new Error(`Unable to load a valid models.dev catalog from ${modelsURL} or local snapshots`)
- return { data: JSON.stringify(cached.data), source: cached.file }
+ return result(cached.data, cached.file)
+}
+
+function result(data: Record, source: string) {
+ const serialized = JSON.stringify(data)
+ return {
+ data: serialized,
+ source,
+ sha256: new Bun.CryptoHasher("sha256").update(serialized).digest("hex"),
+ }
}
function catalog(value: unknown): Record | undefined {
diff --git a/packages/deepagent-code/src/session/message-v2.ts b/packages/deepagent-code/src/session/message-v2.ts
index a4f9860d..d6356e82 100644
--- a/packages/deepagent-code/src/session/message-v2.ts
+++ b/packages/deepagent-code/src/session/message-v2.ts
@@ -37,6 +37,8 @@ import { sql } from "drizzle-orm"
import {
MessageTable,
PartTable,
+ SessionForkAdmissionTable,
+ SessionForkIntentTable,
SessionPromptEpochMessageTable,
SessionTable,
SessionWorldStateBaselineTable,
@@ -1252,7 +1254,52 @@ const migrateHistoryAuthority = Effect.fn("MessageV2.migrateHistoryAuthority")(f
? (deepagent as { task_fork_manifest?: unknown }).task_fork_manifest
: session.metadata?.task_fork_manifest
const foregroundManifest = session.metadata?.forkedFrom
- if (taskManifest || foregroundManifest) {
+ const foregroundMigration =
+ foregroundManifest && typeof foregroundManifest === "object"
+ ? (foregroundManifest as Record)
+ : undefined
+ const migrationAdmission =
+ foregroundMigration?.legacyMigrationVersion === 1 &&
+ (foregroundMigration.manifestState === "prepared" || foregroundMigration.manifestState === "complete") &&
+ typeof foregroundMigration.forkIntentID === "string" &&
+ typeof foregroundMigration.parentSessionID === "string"
+ ? yield* db
+ .select({
+ intent_id: SessionForkAdmissionTable.intent_id,
+ fork_mode: SessionForkAdmissionTable.fork_mode,
+ source_session_id: SessionForkAdmissionTable.source_session_id,
+ target_session_id: SessionForkAdmissionTable.target_session_id,
+ state: SessionForkAdmissionTable.state,
+ })
+ .from(SessionForkAdmissionTable)
+ .where(eq(SessionForkAdmissionTable.intent_id, foregroundMigration.forkIntentID))
+ .get()
+ .pipe(Effect.orDie)
+ : undefined
+ const migrationIntent =
+ foregroundMigration?.legacyMigrationVersion === 1 &&
+ foregroundMigration.manifestState === "complete" &&
+ typeof foregroundMigration.forkIntentID === "string"
+ ? yield* db
+ .select({
+ state: SessionForkIntentTable.state,
+ side_effects_completed_at: SessionForkIntentTable.side_effects_completed_at,
+ })
+ .from(SessionForkIntentTable)
+ .where(eq(SessionForkIntentTable.intent_id, foregroundMigration.forkIntentID))
+ .get()
+ .pipe(Effect.orDie)
+ : undefined
+ const verifiedForegroundMigration =
+ migrationAdmission?.fork_mode === "foreground" &&
+ migrationAdmission.source_session_id === foregroundMigration?.parentSessionID &&
+ migrationAdmission.target_session_id === input.sessionID &&
+ ((foregroundMigration?.manifestState === "prepared" && migrationAdmission.state === "ready") ||
+ (foregroundMigration?.manifestState === "complete" &&
+ migrationAdmission.state === "manifest_committed" &&
+ migrationIntent?.state === "complete" &&
+ migrationIntent.side_effects_completed_at !== null))
+ if (taskManifest || (foregroundManifest && !verifiedForegroundMigration)) {
const reason = taskManifest
? "legacy task fork has no verifiable sanitation manifest"
: "legacy foreground fork has no verifiable source projection manifest"
@@ -1264,6 +1311,7 @@ const migrateHistoryAuthority = Effect.fn("MessageV2.migrateHistoryAuthority")(f
}
const needsWorldStateBaseline =
+ verifiedForegroundMigration ||
(input.existing?.epoch ?? 0) > 0 ||
input.chronological.some(
(message) => message.info.role === "user" && message.parts.some((part) => part.type === "compaction"),
@@ -1345,7 +1393,7 @@ const migrateHistoryAuthority = Effect.fn("MessageV2.migrateHistoryAuthority")(f
first_window_id: windowID,
previous_window_id: null,
window_id: windowID,
- world_state_baseline_hash: candidate.row.epoch > 0 ? baseline!.hash : null,
+ world_state_baseline_hash: candidate.row.epoch > 0 || verifiedForegroundMigration ? baseline!.hash : null,
authority_state: "ready" as const,
recovery_reason: null,
}
@@ -1386,7 +1434,7 @@ const migrateHistoryAuthority = Effect.fn("MessageV2.migrateHistoryAuthority")(f
)
.run()
}
- if (candidate.row.epoch > 0) {
+ if (candidate.row.epoch > 0 || verifiedForegroundMigration) {
yield* tx
.delete(SessionWorldStateBaselineTable)
.where(
diff --git a/packages/deepagent-code/src/session/session.ts b/packages/deepagent-code/src/session/session.ts
index a32b28d5..9b7675f6 100644
--- a/packages/deepagent-code/src/session/session.ts
+++ b/packages/deepagent-code/src/session/session.ts
@@ -26,6 +26,7 @@ import { sql } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { lt } from "drizzle-orm"
import { or } from "drizzle-orm"
+import { notLike } from "drizzle-orm"
import type { SQL } from "drizzle-orm"
import {
MessageTable,
@@ -46,6 +47,7 @@ import { MessageV2 } from "./message-v2"
import {
collectSessionWorldStateBaseline,
forwardLedgerOnForkRequired,
+ loadForkOrigin,
persistForkOriginRequired,
} from "./context-ledger"
import { containsPath, type InstanceContext } from "../project/instance-context"
@@ -228,6 +230,111 @@ function getForkedTitle(title: string): string {
// Fork lineage is capped at MAX_FORK_DEPTH levels (root → fork → fork-of-fork = 3), i.e. at most two
// successive forks. The tree UI mirrors this cap when nesting sessions under their origin.
export const MAX_FORK_DEPTH = 3
+const LEGACY_FOREGROUND_FORK_MIGRATION_VERSION = 1
+const LEGACY_FOREGROUND_FORK_MIGRATION_PREFIX = "legacy_foreground_fork_migration_"
+
+type LegacyForegroundForkOrigin = {
+ parentSessionID: SessionID
+ parentTitle?: string
+ cutoffMessageID?: MessageID
+ forkedAt: number
+ forkIntentID?: string
+ manifestState?: string
+ legacyMigrationVersion?: number
+}
+
+function legacyForegroundForkOrigin(metadata: Info["metadata"]): LegacyForegroundForkOrigin | undefined {
+ const value = metadata?.forkedFrom
+ if (!value || typeof value !== "object") return
+ const origin = value as Record
+ if (typeof origin.parentSessionID !== "string") return
+ if (typeof origin.forkedAt !== "number" || !Number.isFinite(origin.forkedAt)) return
+ if (origin.cutoffMessageID !== undefined && typeof origin.cutoffMessageID !== "string") return
+ if (origin.parentTitle !== undefined && typeof origin.parentTitle !== "string") return
+ if (
+ (origin.manifestVersion !== undefined || origin.forkIntentID !== undefined) &&
+ !(
+ origin.legacyMigrationVersion === LEGACY_FOREGROUND_FORK_MIGRATION_VERSION &&
+ origin.manifestState === "prepared" &&
+ typeof origin.forkIntentID === "string"
+ )
+ )
+ return
+ return {
+ parentSessionID: SessionID.make(origin.parentSessionID),
+ ...(typeof origin.parentTitle === "string" ? { parentTitle: origin.parentTitle } : {}),
+ ...(typeof origin.cutoffMessageID === "string" ? { cutoffMessageID: MessageID.make(origin.cutoffMessageID) } : {}),
+ forkedAt: origin.forkedAt,
+ ...(typeof origin.forkIntentID === "string" ? { forkIntentID: origin.forkIntentID } : {}),
+ ...(typeof origin.manifestState === "string" ? { manifestState: origin.manifestState } : {}),
+ ...(typeof origin.legacyMigrationVersion === "number"
+ ? { legacyMigrationVersion: origin.legacyMigrationVersion }
+ : {}),
+ }
+}
+
+function legacyForkProjectionFingerprint(messages: readonly SessionV1.WithParts[]) {
+ const ordinals = new Map(messages.map((message, ordinal) => [message.info.id, ordinal]))
+ return `lfp${LEGACY_FOREGROUND_FORK_MIGRATION_VERSION}_${Hash.sha256(
+ CanonicalJson.stringify({
+ version: LEGACY_FOREGROUND_FORK_MIGRATION_VERSION,
+ messages: messages.map((message, messageOrdinal) => ({
+ created: message.info.time.created,
+ info:
+ message.info.role === "user"
+ ? {
+ id: messageOrdinal,
+ role: message.info.role,
+ format: message.info.format,
+ agent: message.info.agent,
+ model: message.info.model,
+ system: message.info.system,
+ tools: message.info.tools,
+ metadata: message.info.metadata,
+ }
+ : {
+ id: messageOrdinal,
+ role: message.info.role,
+ error: message.info.error,
+ parentID: ordinals.get(message.info.parentID) ?? `external:${message.info.parentID}`,
+ modelID: message.info.modelID,
+ providerID: message.info.providerID,
+ providerAttemptID: message.info.providerAttemptID,
+ mode: message.info.mode,
+ agent: message.info.agent,
+ path: message.info.path,
+ summary: message.info.summary,
+ structured: message.info.structured,
+ variant: message.info.variant,
+ finish: message.info.finish,
+ },
+ parts: message.parts.map((part, partOrdinal) => ({
+ ...Object.fromEntries(
+ Object.entries(part).filter(
+ ([key]) => key !== "id" && key !== "sessionID" && key !== "messageID" && key !== "time",
+ ),
+ ),
+ id: `${messageOrdinal}:${partOrdinal}`,
+ ...(part.type === "compaction" && part.tail_start_id
+ ? { tail_start_id: ordinals.get(part.tail_start_id) ?? `external:${part.tail_start_id}` }
+ : {}),
+ })),
+ })),
+ }),
+ )}`
+}
+
+function legacyForkSourcePrefix(
+ messages: readonly SessionV1.WithParts[],
+ origin: LegacyForegroundForkOrigin,
+): SessionV1.WithParts[] | undefined {
+ if (origin.cutoffMessageID) {
+ if (!messages.some((message) => message.info.id === origin.cutoffMessageID)) return
+ const cutoffIndex = messages.findIndex((message) => message.info.id >= origin.cutoffMessageID!)
+ return cutoffIndex < 0 ? [...messages] : messages.slice(0, cutoffIndex)
+ }
+ return messages.filter((message) => message.info.time.created <= origin.forkedAt)
+}
function sessionPath(worktree: string, cwd: string) {
return path.relative(path.resolve(worktree), cwd).replaceAll("\\", "/")
@@ -1230,6 +1337,505 @@ export const layer: Layer.Layer<
)
})
+ function migrateLegacyForegroundFork(
+ sessionID: SessionID,
+ lineage: readonly SessionID[] = [],
+ ): Effect.Effect {
+ return Effect.gen(function* () {
+ if (lineage.includes(sessionID) || lineage.length >= MAX_FORK_DEPTH) {
+ return yield* new ForkConflict({
+ intentID: `${LEGACY_FOREGROUND_FORK_MIGRATION_PREFIX}${sessionID}`,
+ reason: "legacy foreground fork lineage contains a cycle or exceeds the depth limit",
+ })
+ }
+ const target = yield* get(sessionID).pipe(
+ Effect.mapError(
+ (error) =>
+ new ForkConflict({
+ intentID: `${LEGACY_FOREGROUND_FORK_MIGRATION_PREFIX}${sessionID}`,
+ reason: `legacy fork child is unavailable: ${error.message}`,
+ }),
+ ),
+ )
+ const origin = legacyForegroundForkOrigin(target.metadata)
+ if (!origin) {
+ return yield* new ForkConflict({
+ intentID: `${LEGACY_FOREGROUND_FORK_MIGRATION_PREFIX}${sessionID}`,
+ reason: "legacy foreground fork manifest is not compatible with migration",
+ })
+ }
+ if (target.parentID) {
+ return yield* new ForkConflict({
+ intentID: `${LEGACY_FOREGROUND_FORK_MIGRATION_PREFIX}${sessionID}`,
+ reason: "legacy foreground fork has a task parent binding",
+ })
+ }
+ const intentID =
+ origin.forkIntentID ??
+ `${LEGACY_FOREGROUND_FORK_MIGRATION_PREFIX}${Hash.sha256(
+ CanonicalJson.stringify({
+ version: LEGACY_FOREGROUND_FORK_MIGRATION_VERSION,
+ sessionID,
+ parentSessionID: origin.parentSessionID,
+ cutoffMessageID: origin.cutoffMessageID,
+ forkedAt: origin.forkedAt,
+ }),
+ ).slice(0, 32)}`
+ const existingIntent = yield* db
+ .select()
+ .from(SessionForkIntentTable)
+ .where(eq(SessionForkIntentTable.intent_id, intentID))
+ .get()
+ .pipe(Effect.orDie)
+ if (existingIntent?.state === "complete" && existingIntent.side_effects_completed_at !== null) return
+ if (existingIntent) {
+ return yield* new ForkConflict({
+ intentID,
+ reason: existingIntent.recovery_reason ?? `legacy migration intent is ${existingIntent.state}`,
+ })
+ }
+
+ const parent = yield* get(origin.parentSessionID).pipe(
+ Effect.mapError(
+ (error) =>
+ new ForkConflict({
+ intentID,
+ reason: `legacy fork parent is unavailable: ${error.message}`,
+ }),
+ ),
+ )
+ if (parent.projectID !== target.projectID || parent.id === target.id) {
+ return yield* new ForkConflict({
+ intentID,
+ reason: "legacy fork parent and child are not in the same project",
+ })
+ }
+ const parentOrigin = legacyForegroundForkOrigin(parent.metadata)
+ if (parentOrigin) yield* migrateLegacyForegroundFork(parent.id, [...lineage, sessionID])
+
+ const quarantinedPart = yield* db
+ .select({ part_id: SessionPartIntegrityQuarantineTable.part_id })
+ .from(SessionPartIntegrityQuarantineTable)
+ .where(
+ or(
+ eq(SessionPartIntegrityQuarantineTable.part_session_id, target.id),
+ eq(SessionPartIntegrityQuarantineTable.message_session_id, target.id),
+ eq(SessionPartIntegrityQuarantineTable.part_session_id, parent.id),
+ eq(SessionPartIntegrityQuarantineTable.message_session_id, parent.id),
+ ),
+ )
+ .get()
+ .pipe(Effect.orDie)
+ if (quarantinedPart) {
+ return yield* new ForkConflict({
+ intentID,
+ reason: `legacy fork history contains quarantined Part ${quarantinedPart.part_id}`,
+ })
+ }
+
+ const sourceProjection = yield* MessageV2.promptHistoryProjectionEffect(parent.id).pipe(
+ Effect.provideService(Database.Service, database),
+ Effect.mapError(
+ (error) =>
+ new ForkConflict({
+ intentID,
+ reason: `legacy fork source history is unavailable: ${
+ error instanceof MessageV2.HistoryAuthorityError ? error.reason : error.message
+ }`,
+ }),
+ ),
+ )
+ const sourceSession = yield* db
+ .select({ mutation_epoch: SessionTable.mutation_epoch })
+ .from(SessionTable)
+ .where(eq(SessionTable.id, parent.id))
+ .get()
+ .pipe(Effect.orDie)
+ if (!sourceSession) return yield* new ForkConflict({ intentID, reason: "legacy fork parent row disappeared" })
+
+ const sourcePhysical = yield* MessageV2.stream(parent.id).pipe(
+ Effect.provideService(Database.Service, database),
+ Effect.map((messages) => messages.reverse()),
+ )
+ const targetPhysical = yield* MessageV2.stream(target.id).pipe(
+ Effect.provideService(Database.Service, database),
+ Effect.map((messages) => messages.reverse()),
+ )
+ const sourcePrefix = legacyForkSourcePrefix(sourcePhysical, origin)
+ if (!sourcePrefix) {
+ return yield* new ForkConflict({
+ intentID,
+ reason: origin.cutoffMessageID
+ ? `legacy fork cutoff ${origin.cutoffMessageID} is absent from the parent history`
+ : "legacy fork parent history cannot be bounded at fork time",
+ })
+ }
+ if (target.time.created < origin.forkedAt) {
+ return yield* new ForkConflict({ intentID, reason: "legacy fork child predates its fork timestamp" })
+ }
+ const targetPrefix = targetPhysical.slice(0, sourcePrefix.length)
+ if (
+ targetPrefix.length !== sourcePrefix.length ||
+ legacyForkProjectionFingerprint(sourcePrefix) !== legacyForkProjectionFingerprint(targetPrefix)
+ ) {
+ return yield* new ForkConflict({
+ intentID,
+ reason: "legacy fork child prefix does not match the verified parent cutoff projection",
+ })
+ }
+ const sourcePrefixHash = legacyForkProjectionFingerprint(sourcePrefix)
+ const targetPrefixHash = legacyForkProjectionFingerprint(targetPrefix)
+ const targetPhysicalHash = HistoryAuthority.hash(targetPhysical)
+ const requestHash = Hash.sha256(
+ CanonicalJson.stringify({
+ version: LEGACY_FOREGROUND_FORK_MIGRATION_VERSION,
+ sourceSessionID: parent.id,
+ targetSessionID: target.id,
+ sourcePromptEpoch: sourceProjection.epoch,
+ sourceWindowID: sourceProjection.window.windowID,
+ sourceEffectiveHistoryHash: sourceProjection.effectiveHistoryHash,
+ sourceMutationEpoch: sourceSession.mutation_epoch,
+ sourceMessageCount: sourceProjection.messages.length,
+ sourceCutoffMessageID: origin.cutoffMessageID,
+ projectionVersion: sourceProjection.projectionVersion,
+ sourcePrefixHash,
+ targetPrefixHash,
+ targetPhysicalHash,
+ targetPhysicalMessageCount: targetPhysical.length,
+ clonedMessageCount: sourcePrefix.length,
+ clonedPartCount: targetPrefix.reduce((total, message) => total + message.parts.length, 0),
+ }),
+ )
+ const preparedMetadata = {
+ ...target.metadata,
+ forkedFrom: {
+ ...(target.metadata?.forkedFrom as Record),
+ manifestVersion: 1,
+ manifestState: "prepared",
+ forkIntentID: intentID,
+ forkMode: "foreground",
+ parentSessionID: parent.id,
+ parentTitle: parent.title,
+ ...(origin.cutoffMessageID ? { cutoffMessageID: origin.cutoffMessageID } : {}),
+ sourcePromptEpoch: sourceProjection.epoch,
+ sourceWindowID: sourceProjection.window.windowID,
+ sourceEffectiveHistoryHash: sourceProjection.effectiveHistoryHash,
+ sourceMutationEpoch: sourceSession.mutation_epoch,
+ sourceMessageCount: sourceProjection.messages.length,
+ projectionVersion: sourceProjection.projectionVersion,
+ sanitationPolicyVersion: 1,
+ legacyMigrationVersion: LEGACY_FOREGROUND_FORK_MIGRATION_VERSION,
+ legacySourcePrefixHash: sourcePrefixHash,
+ legacyTargetPrefixHash: targetPrefixHash,
+ legacyTargetPhysicalHash: targetPhysicalHash,
+ legacyTargetPhysicalMessageCount: targetPhysical.length,
+ legacyClonedMessageCount: sourcePrefix.length,
+ legacyClonedPartCount: targetPrefix.reduce((total, message) => total + message.parts.length, 0),
+ legacyMigrationRequestHash: requestHash,
+ forkedAt: origin.forkedAt,
+ },
+ }
+
+ yield* db
+ .transaction(
+ (tx) =>
+ Effect.gen(function* () {
+ const currentIntent = yield* tx
+ .select()
+ .from(SessionForkIntentTable)
+ .where(eq(SessionForkIntentTable.intent_id, intentID))
+ .get()
+ if (currentIntent?.state === "complete" && currentIntent.side_effects_completed_at !== null) return
+ if (currentIntent)
+ return yield* Effect.fail(
+ new ForkConflict({
+ intentID,
+ reason: currentIntent.recovery_reason ?? `legacy migration intent is ${currentIntent.state}`,
+ }),
+ )
+ const currentAdmission = yield* tx
+ .select()
+ .from(SessionForkAdmissionTable)
+ .where(eq(SessionForkAdmissionTable.intent_id, intentID))
+ .get()
+ if (currentAdmission) {
+ if (currentAdmission.request_hash !== requestHash || currentAdmission.state !== "ready")
+ return yield* Effect.fail(
+ new ForkConflict({
+ intentID,
+ reason:
+ currentAdmission.recovery_reason ?? `legacy migration admission is ${currentAdmission.state}`,
+ }),
+ )
+ } else {
+ yield* tx
+ .insert(SessionForkAdmissionTable)
+ .values({
+ intent_id: intentID,
+ request_hash: requestHash,
+ fork_mode: "foreground",
+ source_session_id: parent.id,
+ source_prompt_epoch: sourceProjection.epoch,
+ source_window_id: sourceProjection.window.windowID,
+ source_effective_history_hash: sourceProjection.effectiveHistoryHash,
+ source_mutation_epoch: sourceSession.mutation_epoch,
+ source_message_count: sourceProjection.messages.length,
+ source_cutoff_message_id: origin.cutoffMessageID ?? null,
+ projection_version: sourceProjection.projectionVersion,
+ sanitation_policy_version: 1,
+ requested_directory: target.directory,
+ isolation_mode: "none",
+ requested_target_session_id: target.id,
+ target_session_id: target.id,
+ child_depth: null,
+ task_request_hash: null,
+ worktree_directory: null,
+ worktree_branch: null,
+ worktree_base_commit: null,
+ state: "ready",
+ recovery_reason: null,
+ time_created: Date.now(),
+ time_updated: Date.now(),
+ })
+ .run()
+ }
+ const history = yield* tx
+ .select({ state: SessionHistoryStateTable.state, reason: SessionHistoryStateTable.reason })
+ .from(SessionHistoryStateTable)
+ .where(eq(SessionHistoryStateTable.session_id, target.id))
+ .get()
+ if (history?.state === "recovery_required") {
+ if (history.reason !== "legacy foreground fork has no verifiable source projection manifest")
+ return yield* Effect.fail(
+ new ForkConflict({ intentID, reason: history.reason ?? "legacy fork history requires recovery" }),
+ )
+ yield* tx
+ .delete(SessionPromptEpochMessageTable)
+ .where(eq(SessionPromptEpochMessageTable.session_id, target.id))
+ .run()
+ yield* tx
+ .delete(SessionWorldStateBaselineTable)
+ .where(eq(SessionWorldStateBaselineTable.session_id, target.id))
+ .run()
+ yield* tx
+ .delete(SessionPromptEpochTable)
+ .where(eq(SessionPromptEpochTable.session_id, target.id))
+ .run()
+ yield* tx
+ .delete(SessionHistoryStateTable)
+ .where(eq(SessionHistoryStateTable.session_id, target.id))
+ .run()
+ }
+ yield* tx
+ .update(SessionTable)
+ .set({ metadata: preparedMetadata })
+ .where(eq(SessionTable.id, target.id))
+ .run()
+ }),
+ { behavior: "immediate" },
+ )
+ .pipe(
+ Effect.mapError((error) =>
+ error instanceof ForkConflict
+ ? error
+ : new ForkConflict({
+ intentID,
+ reason: `legacy migration admission transaction failed: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ }),
+ ),
+ )
+ const completed = yield* Effect.gen(function* () {
+ const targetProjection = yield* MessageV2.promptHistoryProjectionEffect(target.id).pipe(
+ Effect.provideService(Database.Service, database),
+ Effect.mapError(
+ (error) =>
+ new ForkConflict({
+ intentID,
+ reason: `legacy fork target history migration failed: ${
+ error instanceof MessageV2.HistoryAuthorityError ? error.reason : error.message
+ }`,
+ }),
+ ),
+ )
+ if (!targetProjection.worldStateBaselineHash) {
+ return yield* new ForkConflict({
+ intentID,
+ reason: "legacy fork target history has no World State baseline after migration",
+ })
+ }
+ const targetWorldState = yield* MessageV2.promptWorldStateProjectionEffect(target.id).pipe(
+ Effect.provideService(Database.Service, database),
+ Effect.mapError(
+ (error) =>
+ new ForkConflict({
+ intentID,
+ reason: `legacy fork target World State validation failed: ${
+ error instanceof MessageV2.HistoryAuthorityError ? error.reason : error.message
+ }`,
+ }),
+ ),
+ )
+ if (!targetWorldState || targetWorldState.hash !== targetProjection.worldStateBaselineHash) {
+ return yield* new ForkConflict({ intentID, reason: "legacy fork World State baseline hash mismatch" })
+ }
+ if (!loadForkOrigin(target.id)) {
+ yield* forwardLedgerOnForkRequired({ parentSessionID: parent.id, forkSessionID: target.id })
+ }
+ yield* persistForkOriginRequired({
+ forkSessionID: target.id,
+ origin: {
+ parentSessionID: parent.id,
+ ...(origin.cutoffMessageID ? { cutoffMessageID: origin.cutoffMessageID } : {}),
+ forkedAt: origin.forkedAt,
+ },
+ })
+ return yield* db
+ .transaction(
+ (tx) =>
+ Effect.gen(function* () {
+ const admission = yield* tx
+ .select()
+ .from(SessionForkAdmissionTable)
+ .where(eq(SessionForkAdmissionTable.intent_id, intentID))
+ .get()
+ if (!admission || admission.state !== "ready" || admission.request_hash !== requestHash)
+ return yield* Effect.fail(
+ new ForkConflict({ intentID, reason: "legacy migration admission changed during recovery" }),
+ )
+ const sourceMutation = yield* tx
+ .select({ mutation_epoch: SessionTable.mutation_epoch })
+ .from(SessionTable)
+ .where(eq(SessionTable.id, parent.id))
+ .get()
+ if (!sourceMutation || sourceMutation.mutation_epoch !== sourceSession.mutation_epoch)
+ return yield* Effect.fail(
+ new ForkConflict({ intentID, reason: "legacy fork source changed during migration" }),
+ )
+ const currentTarget = yield* tx
+ .select({ metadata: SessionTable.metadata })
+ .from(SessionTable)
+ .where(eq(SessionTable.id, target.id))
+ .get()
+ const currentManifest =
+ currentTarget?.metadata?.forkedFrom && typeof currentTarget.metadata.forkedFrom === "object"
+ ? (currentTarget.metadata.forkedFrom as Record)
+ : undefined
+ if (
+ currentManifest?.legacyMigrationRequestHash !== requestHash ||
+ currentManifest.manifestState !== "prepared"
+ )
+ return yield* Effect.fail(
+ new ForkConflict({ intentID, reason: "legacy migration manifest changed during recovery" }),
+ )
+ const now = Date.now()
+ yield* tx
+ .insert(SessionForkIntentTable)
+ .values({
+ intent_id: intentID,
+ request_hash: requestHash,
+ fork_mode: "foreground",
+ source_session_id: parent.id,
+ source_prompt_epoch: sourceProjection.epoch,
+ source_window_id: sourceProjection.window.windowID,
+ source_effective_history_hash: sourceProjection.effectiveHistoryHash,
+ source_mutation_epoch: sourceSession.mutation_epoch,
+ source_message_count: sourceProjection.messages.length,
+ source_cutoff_message_id: origin.cutoffMessageID ?? null,
+ projection_version: sourceProjection.projectionVersion,
+ sanitation_policy_version: 1,
+ target_session_id: target.id,
+ target_prompt_epoch: targetProjection.epoch,
+ target_window_id: targetProjection.window.windowID,
+ target_effective_history_hash: targetProjection.effectiveHistoryHash,
+ target_world_state_baseline_hash: targetWorldState.hash,
+ cloned_message_count: sourcePrefix.length,
+ cloned_part_count: targetPrefix.reduce((total, message) => total + message.parts.length, 0),
+ state: "complete",
+ event_cursor: 0,
+ event_count: 0,
+ delivery_owner: null,
+ lease_expires_at: null,
+ delivery_attempts: 0,
+ recovery_reason: null,
+ time_created: now,
+ time_updated: now,
+ time_committed: now,
+ time_completed: now,
+ side_effects_completed_at: null,
+ })
+ .run()
+ yield* tx
+ .update(SessionForkIntentTable)
+ .set({ side_effects_completed_at: now, time_updated: now })
+ .where(eq(SessionForkIntentTable.intent_id, intentID))
+ .run()
+ yield* tx
+ .update(SessionForkAdmissionTable)
+ .set({ state: "manifest_committed", recovery_reason: null, time_updated: now })
+ .where(
+ and(
+ eq(SessionForkAdmissionTable.intent_id, intentID),
+ eq(SessionForkAdmissionTable.state, "ready"),
+ ),
+ )
+ .run()
+ yield* tx
+ .update(SessionTable)
+ .set({
+ metadata: {
+ ...target.metadata,
+ forkedFrom: {
+ ...currentManifest,
+ manifestState: "complete",
+ targetPromptEpoch: targetProjection.epoch,
+ targetWindowID: targetProjection.window.windowID,
+ targetEffectiveHistoryHash: targetProjection.effectiveHistoryHash,
+ targetWorldStateBaselineHash: targetWorldState.hash,
+ legacyMigrationCompletedAt: now,
+ },
+ },
+ })
+ .where(eq(SessionTable.id, target.id))
+ .run()
+ return
+ }),
+ { behavior: "immediate" },
+ )
+ .pipe(
+ Effect.mapError((error) =>
+ error instanceof ForkConflict
+ ? error
+ : new ForkConflict({
+ intentID,
+ reason: `legacy migration completion transaction failed: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ }),
+ ),
+ )
+ }).pipe(
+ Effect.catch((error: unknown) =>
+ Effect.gen(function* () {
+ const reason =
+ error instanceof ForkConflict ? error.reason : error instanceof Error ? error.message : String(error)
+ yield* db
+ .update(SessionForkAdmissionTable)
+ .set({ state: "recovery_required", recovery_reason: reason, time_updated: Date.now() })
+ .where(
+ and(eq(SessionForkAdmissionTable.intent_id, intentID), eq(SessionForkAdmissionTable.state, "ready")),
+ )
+ .run()
+ .pipe(Effect.orDie)
+ return yield* new ForkConflict({ intentID, reason })
+ }),
+ ),
+ )
+ if (completed) return
+ })
+ }
+
const forkUnlocked = Effect.fn("Session.fork")(function* (input: {
sessionID: SessionID
intentID: string
@@ -2013,6 +2619,25 @@ export const layer: Layer.Layer<
const recoverForks: Interface["recoverForks"] = Effect.fn("Session.recoverForks")(function* () {
const now = Date.now()
const ctx = yield* InstanceState.context
+ const legacyAdmissions = yield* db
+ .select({ target_session_id: SessionForkAdmissionTable.target_session_id })
+ .from(SessionForkAdmissionTable)
+ .innerJoin(SessionTable, eq(SessionTable.id, SessionForkAdmissionTable.source_session_id))
+ .where(
+ and(
+ eq(SessionTable.project_id, ctx.project.id),
+ like(SessionForkAdmissionTable.intent_id, `${LEGACY_FOREGROUND_FORK_MIGRATION_PREFIX}%`),
+ eq(SessionForkAdmissionTable.state, "ready"),
+ ),
+ )
+ .all()
+ .pipe(Effect.orDie)
+ yield* Effect.forEach(
+ legacyAdmissions,
+ ({ target_session_id }) =>
+ migrateLegacyForegroundFork(SessionID.make(target_session_id)).pipe(Effect.catchCause(() => Effect.void)),
+ { discard: true },
+ )
const recoverableAdmissions = yield* db
.select({ admission: SessionForkAdmissionTable })
.from(SessionForkAdmissionTable)
@@ -2021,6 +2646,7 @@ export const layer: Layer.Layer<
and(
eq(SessionTable.project_id, ctx.project.id),
inArray(SessionForkAdmissionTable.state, ["admitted", "provisioning", "ready"] as const),
+ notLike(SessionForkAdmissionTable.intent_id, `${LEGACY_FOREGROUND_FORK_MIGRATION_PREFIX}%`),
),
)
.all()
@@ -2146,7 +2772,22 @@ export const layer: Layer.Layer<
.where(eq(SessionForkAdmissionTable.target_session_id, sessionID))
.get()
.pipe(Effect.orDie)
- if (admission)
+ if (admission && admission.intent_id.startsWith(LEGACY_FOREGROUND_FORK_MIGRATION_PREFIX)) {
+ if (admission.state !== "ready")
+ return yield* new UnavailableError({
+ sessionID,
+ reason: `legacy fork migration admission ${admission.intent_id} is ${admission.state}`,
+ })
+ yield* migrateLegacyForegroundFork(sessionID).pipe(
+ Effect.mapError(
+ (error) =>
+ new UnavailableError({
+ sessionID,
+ reason: error.reason,
+ }),
+ ),
+ )
+ } else if (admission)
return yield* new UnavailableError({
sessionID,
reason: `fork admission ${admission.intent_id} is ${admission.state}`,
@@ -2166,11 +2807,17 @@ export const layer: Layer.Layer<
sessionID,
reason: "legacy task fork has no verifiable sanitation manifest",
})
- if (session?.metadata?.forkedFrom)
- return yield* new UnavailableError({
- sessionID,
- reason: "legacy foreground fork has no verifiable source projection manifest",
- })
+ if (session?.metadata?.forkedFrom) {
+ yield* migrateLegacyForegroundFork(sessionID).pipe(
+ Effect.mapError(
+ (error) =>
+ new UnavailableError({
+ sessionID,
+ reason: error.reason,
+ }),
+ ),
+ )
+ }
}
const history = yield* db
.select({
diff --git a/packages/deepagent-code/test/script/models-data.test.ts b/packages/deepagent-code/test/script/models-data.test.ts
index 6eee3040..d7c038b6 100644
--- a/packages/deepagent-code/test/script/models-data.test.ts
+++ b/packages/deepagent-code/test/script/models-data.test.ts
@@ -35,6 +35,7 @@ describe("models.dev build data", () => {
expect(result.source).toBe(file)
expect(JSON.parse(result.data)).toEqual(catalog)
+ expect(result.sha256).toBe(new Bun.CryptoHasher("sha256").update(JSON.stringify(catalog)).digest("hex"))
})
test("fetches a fresh catalog and persists the last good copy", async () => {
@@ -79,4 +80,27 @@ describe("models.dev build data", () => {
"Configured models.dev snapshot is invalid",
)
})
+
+ test("does not read or rewrite a builder-local cache when the network is unavailable", async () => {
+ await using directory = await fixture()
+ const cacheFile = path.join(directory.root, "models.json")
+ const builderOnly = {
+ "builder-only": {
+ id: "builder-only",
+ name: "Builder only",
+ models: { leaked: { id: "leaked" } },
+ },
+ }
+ await Bun.write(cacheFile, JSON.stringify(builderOnly))
+
+ const result = await loadModelsData({
+ environment: { DEEPAGENT_CODE_MODELS_URL: "http://127.0.0.1:1" },
+ cacheFile,
+ requestTimeoutMs: 200,
+ })
+
+ expect(result.source).toBe(path.resolve(import.meta.dir, "../tool/fixtures/models-api.json"))
+ expect(JSON.parse(result.data)["builder-only"]).toBeUndefined()
+ expect(await Bun.file(cacheFile).json()).toEqual(builderOnly)
+ })
})
diff --git a/packages/deepagent-code/test/server/httpapi-provider.test.ts b/packages/deepagent-code/test/server/httpapi-provider.test.ts
index a94850e2..172cf88c 100644
--- a/packages/deepagent-code/test/server/httpapi-provider.test.ts
+++ b/packages/deepagent-code/test/server/httpapi-provider.test.ts
@@ -263,6 +263,37 @@ function setEnvScoped(key: string, value: string) {
}
describe("provider HttpApi", () => {
+ it.instance(
+ "only autoloads the public hosted provider for a blank installation",
+ Effect.gen(function* () {
+ const directory = (yield* TestInstance).directory
+ yield* setEnvScoped("DEEPAGENT_CODE_AUTH_CONTENT", "{}")
+
+ const response = yield* request("/provider", {
+ headers: { "x-deepagent-code-directory": directory },
+ })
+ expect(response.status).toBe(200)
+
+ const body = yield* response.json
+ expect(providerList(body, "all").length).toBeGreaterThan(0)
+ expect(isRecord(body) && body.connected).toEqual(["deepagent-code"])
+ expect(isRecord(body) && isRecord(body.default) && Object.keys(body.default).length).toBeGreaterThan(0)
+
+ const hosted = providerByID(body, "all", "deepagent-code")
+ expect(hosted).toBeDefined()
+ expect(isRecord(hosted) && isRecord(hosted.models) && Object.keys(hosted.models).length).toBeGreaterThan(0)
+ expect(
+ isRecord(hosted) &&
+ isRecord(hosted.models) &&
+ Object.values(hosted.models).every(
+ (model) => isRecord(model) && isRecord(model.cost) && model.cost.input === 0,
+ ),
+ ).toBe(true)
+ }),
+ projectOptions,
+ 30000,
+ )
+
it.instance.skip(
"returns public v2 provider not found errors",
Effect.gen(function* () {
diff --git a/packages/deepagent-code/test/session/context-window.test.ts b/packages/deepagent-code/test/session/context-window.test.ts
index 1978efb6..10dee3d2 100644
--- a/packages/deepagent-code/test/session/context-window.test.ts
+++ b/packages/deepagent-code/test/session/context-window.test.ts
@@ -5,6 +5,8 @@ import { ModelV2 } from "@deepagent-code/core/model"
import { SessionV1 } from "@deepagent-code/core/v1/session"
import {
PartTable,
+ SessionForkAdmissionTable,
+ SessionForkIntentTable,
SessionHistoryStateTable,
SessionPromptEpochMessageTable,
SessionWorldStateBaselineTable,
@@ -536,4 +538,119 @@ describe("Session context window authority", () => {
yield* sessions.remove(legacy.id)
}),
)
+
+ it.instance("migrates a verifiable legacy foreground fork with post-fork child history", () =>
+ Effect.gen(function* () {
+ const sessions = yield* Session.Service
+ const source = yield* sessions.create({})
+ const first = yield* addUser(source.id, "legacy copied prefix")
+ const cutoff = yield* addUser(source.id, "parent cutoff")
+ const child = yield* sessions.create({
+ metadata: {
+ forkedFrom: {
+ parentSessionID: source.id,
+ cutoffMessageID: cutoff.message.id,
+ forkedAt: Date.now() - 10,
+ },
+ },
+ })
+ const clonedMessage = yield* sessions.updateMessage({
+ ...first.message,
+ id: MessageID.ascending(),
+ sessionID: child.id,
+ })
+ yield* sessions.updatePart({
+ ...first.part,
+ id: PartID.ascending(),
+ messageID: clonedMessage.id,
+ sessionID: child.id,
+ })
+ yield* addUser(child.id, "child-only continuation")
+
+ yield* sessions.assertRunnable(child.id)
+
+ const { db } = yield* Database.Service
+ const intent = yield* db
+ .select()
+ .from(SessionForkIntentTable)
+ .where(eq(SessionForkIntentTable.target_session_id, child.id))
+ .get()
+ .pipe(Effect.orDie)
+ const admission = yield* db
+ .select()
+ .from(SessionForkAdmissionTable)
+ .where(eq(SessionForkAdmissionTable.target_session_id, child.id))
+ .get()
+ .pipe(Effect.orDie)
+ const projection = yield* MessageV2.promptHistoryProjectionEffect(child.id)
+ const metadata = (yield* sessions.get(child.id)).metadata?.forkedFrom as Record | undefined
+
+ expect(intent?.state).toBe("complete")
+ expect(intent?.event_count).toBe(0)
+ expect(intent?.event_cursor).toBe(0)
+ expect(intent?.side_effects_completed_at).not.toBeNull()
+ expect(intent?.cloned_message_count).toBe(1)
+ expect(admission?.state).toBe("manifest_committed")
+ expect(metadata?.manifestState).toBe("complete")
+ expect(projection.messages.filter((message) => message.info.role === "user")).toHaveLength(2)
+ expect(
+ projection.messages.flatMap((message) =>
+ message.parts.filter((part) => part.type === "text").map((part) => part.text),
+ ),
+ ).toEqual(["legacy copied prefix", "child-only continuation"])
+
+ // A later process may need to rebuild the authority rows after the manifest is complete.
+ // The durable intent and side-effect receipt must be sufficient to authorize that rebuild.
+ yield* db
+ .delete(SessionPromptEpochMessageTable)
+ .where(eq(SessionPromptEpochMessageTable.session_id, child.id))
+ .run()
+ yield* db
+ .delete(SessionWorldStateBaselineTable)
+ .where(eq(SessionWorldStateBaselineTable.session_id, child.id))
+ .run()
+ yield* db.delete(SessionPromptEpochTable).where(eq(SessionPromptEpochTable.session_id, child.id)).run()
+ yield* db.delete(SessionHistoryStateTable).where(eq(SessionHistoryStateTable.session_id, child.id)).run()
+ const rebuilt = yield* MessageV2.promptHistoryProjectionEffect(child.id)
+ expect(rebuilt.worldStateBaselineHash).toBeDefined()
+
+ yield* sessions.remove(child.id)
+ yield* sessions.remove(source.id)
+ }),
+ )
+
+ it.instance("rejects a legacy foreground fork whose copied prefix changed", () =>
+ Effect.gen(function* () {
+ const sessions = yield* Session.Service
+ const source = yield* sessions.create({})
+ yield* addUser(source.id, "authoritative source")
+ const cutoff = yield* addUser(source.id, "parent cutoff")
+ const child = yield* sessions.create({
+ metadata: {
+ forkedFrom: {
+ parentSessionID: source.id,
+ cutoffMessageID: cutoff.message.id,
+ forkedAt: Date.now() - 10,
+ },
+ },
+ })
+ yield* addUser(child.id, "tampered child prefix")
+
+ const error = yield* sessions.assertRunnable(child.id).pipe(Effect.flip)
+ const { db } = yield* Database.Service
+ const intent = yield* db
+ .select({ intent_id: SessionForkIntentTable.intent_id })
+ .from(SessionForkIntentTable)
+ .where(eq(SessionForkIntentTable.target_session_id, child.id))
+ .get()
+ .pipe(Effect.orDie)
+
+ expect(error).toBeInstanceOf(Session.UnavailableError)
+ expect(error.reason).toContain("prefix does not match")
+ expect(intent).toBeUndefined()
+
+ yield* sessions.remove(child.id)
+ yield* sessions.remove(source.id)
+ }),
+ )
})
diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts
index cc911e5a..4a9d5850 100644
--- a/packages/desktop/electron.vite.config.ts
+++ b/packages/desktop/electron.vite.config.ts
@@ -56,7 +56,10 @@ export default defineConfig(({ command }) => ({
async writeBundle() {
await mkdir("./out/main/chunks", { recursive: true })
for (const file of await readdir(DEEPAGENT_CODE_SERVER_DIST)) {
- if (!file.endsWith(".wasm") && (command !== "build" || (file !== "node.js" && file !== "node.js.map")))
+ if (
+ !file.endsWith(".wasm") &&
+ (command !== "build" || !["node.js", "node.js.map", "models-dev.build.json"].includes(file))
+ )
continue
await copyFile(`${DEEPAGENT_CODE_SERVER_DIST}/${file}`, `./out/main/chunks/${file}`)
}
diff --git a/packages/desktop/scripts/audit-package.test.ts b/packages/desktop/scripts/audit-package.test.ts
index 7ad913a2..8a1f2b76 100644
--- a/packages/desktop/scripts/audit-package.test.ts
+++ b/packages/desktop/scripts/audit-package.test.ts
@@ -18,6 +18,11 @@ async function fixture() {
describe("auditPackageInputs", () => {
test("accepts generated application assets", async () => {
await using root = await fixture()
+ await mkdir(path.join(root.root, "out", "main", "chunks"), { recursive: true })
+ await writeFile(
+ path.join(root.root, "out", "main", "chunks", "models-dev.build.json"),
+ JSON.stringify({ source: "test/tool/fixtures/models-api.json", sha256: "a".repeat(64) }),
+ )
await expect(auditPackageInputs(root.root)).resolves.toBeUndefined()
})
@@ -44,4 +49,27 @@ describe("auditPackageInputs", () => {
await symlink(path.join(root.root, "out", "main", "index.js"), path.join(root.root, "resources", "linked.js"))
await expect(auditPackageInputs(root.root)).rejects.toThrow("symbolic link")
})
+
+ for (const runtimeFile of [
+ "config.json",
+ "config.jsonc",
+ "deepagent-code.json",
+ "deepagent-code.jsonc",
+ "provider-template.jsonc",
+ "mcp-auth.json",
+ "mcp-secrets.json",
+ "models.json",
+ "model.json",
+ "plugin-meta.json",
+ "sessions.json",
+ "artifact-key.json",
+ "token-keyring.json",
+ "password",
+ ]) {
+ test(`rejects runtime configuration or state file ${runtimeFile}`, async () => {
+ await using root = await fixture()
+ await writeFile(path.join(root.root, "resources", runtimeFile), "{}")
+ await expect(auditPackageInputs(root.root)).rejects.toThrow("runtime user-data file")
+ })
+ }
})
diff --git a/packages/desktop/scripts/audit-package.ts b/packages/desktop/scripts/audit-package.ts
index 97142793..71043465 100644
--- a/packages/desktop/scripts/audit-package.ts
+++ b/packages/desktop/scripts/audit-package.ts
@@ -7,6 +7,20 @@ const forbiddenNames = new Set([
"deepagent-code.db",
"deepagent-code-local.db",
"deepagent.global.dat",
+ "config.json",
+ "config.jsonc",
+ "deepagent-code.json",
+ "deepagent-code.jsonc",
+ "provider-template.jsonc",
+ "mcp-auth.json",
+ "mcp-secrets.json",
+ "models.json",
+ "model.json",
+ "plugin-meta.json",
+ "sessions.json",
+ "artifact-key.json",
+ "token-keyring.json",
+ "password",
"settings.json",
])