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", ])