From f442bc0488ae90d19f2f147cca300c9737dd9cc5 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 11 Aug 2026 14:54:51 +0800 Subject: [PATCH] Fix/context federation hardening rebased (#110) ### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._ --- .github/workflows/desktop-build.yml | 1 + .github/workflows/publish.yml | 2 + .../docs/deepagent-v3-1-manual-test.md | 242 ------- .../docs/deepagent-v3-manual-test.md | 240 ------- packages/deepagent-code/script/build-node.ts | 10 + packages/deepagent-code/script/build.ts | 4 + packages/deepagent-code/script/generate.ts | 9 +- packages/deepagent-code/script/models-data.ts | 38 +- .../deepagent-code/src/session/message-v2.ts | 54 +- .../deepagent-code/src/session/session.ts | 659 +++++++++++++++++- .../test/script/models-data.test.ts | 24 + .../test/server/httpapi-provider.test.ts | 31 + .../test/session/context-window.test.ts | 117 ++++ packages/desktop/electron.vite.config.ts | 5 +- .../desktop/scripts/audit-package.test.ts | 28 + packages/desktop/scripts/audit-package.ts | 14 + 16 files changed, 969 insertions(+), 509 deletions(-) delete mode 100644 packages/deepagent-code/docs/deepagent-v3-1-manual-test.md delete mode 100644 packages/deepagent-code/docs/deepagent-v3-manual-test.md 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", ])