Merge pull request #1574 from deepseek-harness/codex/fix-resume-baseline-dedup

fix(workspace-context): deduplicate baseline on resume
This commit is contained in:
fz
2026-08-06 23:02:40 +08:00
committed by GitHub
23 changed files with 894 additions and 72 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md
2026-06-24-workspace-context.md: 2f3a76ee1d6cedea96f6297bd2746299733f173b
2026-06-24-workspace-context.zh.md: 99ba69b231f3bc7a26cf61c5ab4056b70ca3db54
2026-06-24-workspace-context.md: 3c21b7599e6bb2dd759e4a040c1b999f1b0516cd
2026-06-24-workspace-context.zh.md: e913ecb22b4692e1ee7b96d4f5effe2606775cf8
@@ -30,9 +30,9 @@ The user-global file is fixed at `$DSH_HOME/AGENTS.md`, is not affected by eithe
At the first `agent/pre-step` of an agent-loop instance, the plugin composes one sourced user-role baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so it becomes durable with the direct prompt and reaches the first request. Rejection or an empty first-step decision leaves the baseline in the next-step inbox for a later wakeup. The plugin loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads the configured candidates from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root.
The baseline becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes the complete startup or resume baseline from later deltas, and its change list persists the included scopes and content digests. If a previously queued workspace baseline is still pending, the plugin removes that exact message and prepends its replacement instead of accumulating duplicates.
The baseline becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes a complete baseline from later deltas, its `baselineIdentity` records normalized discovery, precedence, project-root, and budget semantics, and its change list persists the included scopes and content digests. If a previously queued workspace baseline is still pending, the plugin removes that exact message and prepends its replacement instead of accumulating duplicates.
A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the next entering pre-step composes a current baseline and carries it in the same request, including after a remount. A resume always re-composes.
A resumed agent creates a new loop instance over persisted history. At its first `agent/pre-step`, a visible baseline with the current identity remains authoritative while the plugin compares its retained scopes with a complete current rendering. Unchanged and budget-omitted files append nothing; offline additions, edits, removals, and files that leave the retained budget set append `set`, `replace`, or `remove` transitions in the entering batch without mutating or duplicating the original baseline. An incompatible visible baseline is superseded by one complete baseline in current precedence order with explicit replacement language; when no current candidate exists, an explicit empty baseline clears the earlier scopes. A hot plugin remount follows the same rule. If compaction has shadowed the typed baseline, the next entering pre-step composes one complete current baseline and carries it in the same request.
The baseline is a user-role `<system-reminder>` with `Instructions from: <path>` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. The final rendering boundary escapes a literal `</system-reminder>` anywhere in instruction content or model-visible path, scope, and budget metadata before byte accounting completes. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape).
@@ -48,15 +48,15 @@ Shell commands are not discovery triggers. Local bash calls start fresh shells,
### Duplicate Suppression And Change Detection
Every workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. Baselines additionally carry `baseline: true`. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state.
Every workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. Complete baselines additionally carry `baseline: true` and `baselineIdentity`. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state.
At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy.
An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch.
An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata: a compatible visible baseline supplies comparison state rather than causing another complete baseline to be appended. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch.
The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. When compaction shadows that event, the next entering pre-step composes the current baseline and records it in the same request; a successful filesystem touch can instead re-add an unchanged baseline scope or append baseline edits or removals as dynamic messages. Neither path rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request.
The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. Resume retains a compatible baseline and reconciles current baseline and visible dynamic scopes, so changes made while the agent was offline append transitions before the first resumed request. When compaction shadows the baseline event, the next entering pre-step composes the complete current baseline and records it in the same request; a successful filesystem touch can instead re-add an unchanged baseline scope or append baseline edits or removals as dynamic messages. Neither path rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees.
There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully.
There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch, resumed-baseline reconciliation, or entering pre-step that restores a shadowed baseline. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully.
### Byte Budget And Bounded Reads
@@ -82,7 +82,7 @@ Workspace guidance is isolated per session and shared by the demo front doors, W
Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk).
The system is event-driven rather than watch-driven. Edits are not visible at the exact filesystem mutation instant unless that mutation goes through a structured tool; externally changed files are noticed on the next successful structured touch or resume. This keeps the design deterministic and provider-neutral.
The system is event-driven rather than watch-driven. Edits are not visible at the exact filesystem mutation instant unless that mutation goes through a structured tool; externally changed files are noticed on the next successful structured touch, resume reconciliation, or restoration of a shadowed baseline. This keeps the design deterministic and provider-neutral.
## Deferred
@@ -30,9 +30,9 @@ Status: implemented
在 agent loop(智能体循环)实例的第一个 `agent/pre-step`,插件会组合一条带来源的 user 角色基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使其与直接提示词一同成为持久记录并抵达第一次请求。reject 或空的第一步决策会将基线留在 next-step inbox,等待后续唤醒。插件先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录加载已配置候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。
该基线会成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整的启动或恢复基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。若先前排队的 workspace 基线仍在等待,插件会删除该确切消息并 prepend 替代值,而不会累积副本。
该基线会成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整基线与后续增量区分开来,`baselineIdentity` 记录规范化的发现、优先级、项目根目录和预算语义,变更列表则持久保存已纳入的作用域和内容 digest。若先前排队的 workspace 基线仍在等待,插件会删除该确切消息并 prepend 替代值,而不会累积副本。
恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,下一次进入步骤的 pre-step 会组合当前基线并在同一请求中携带它;热重挂后也是如此。恢复则始终重新组合
恢复 agent 会基于持久化历史创建新的 loop 实例。在第一个 `agent/pre-step`,具有当前标识的可见基线仍是权威状态;插件会将其保留的 scope 与当前完整渲染进行比较。未变化和被预算省略的文件不追加任何内容;agent 离线期间新增、编辑、移除或不再属于预算保留集的文件,会在进入步骤的批次中追加 `set``replace``remove` 转换,既不改写也不重复原始基线。不兼容的可见基线会被一条按当前优先级排列的完整基线取代,并以明确措辞说明替换关系;如果当前不存在任何候选文件,一条显式空基线会清除先前的 scope。插件热重挂遵循相同规则。如果压缩(compaction)已遮蔽带类型的基线,下一次进入步骤的 pre-step 会组合一条完整的当前基线并在同一请求中携带它。
基线是一条 user 角色的 `<system-reminder>`,包含 `Instructions from: <path>` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。最终渲染边界会在完成字节核算前,转义指令内容或模型可见的路径、scope 与预算元数据中出现的字面量 `</system-reminder>`。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。
@@ -48,15 +48,15 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,
### 重复抑制与变更检测
每个工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }``digest` 是对已加载内容计算的 SHA-1。基线还会额外携带 `baseline: true`。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。
每个工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }``digest` 是对已加载内容计算的 SHA-1。完整基线还会额外携带 `baseline: true``baselineIdentity`。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。
协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。
路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。
路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作:兼容的可见基线会提供比较状态,而不会导致再次追加完整基线。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。
只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。当压缩遮蔽事件时,下一次进入步骤的 pre-step 会组合当前基线,并在同一请求中记录它;也可以改由一次成功的文件系统触碰重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息。两条路径都不会重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。
只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。恢复会保留兼容的基线,并对账当前基线和可见的动态 scope,因此 agent 离线期间的变更会在第一次恢复请求前追加为转换。当压缩遮蔽基线事件时,下一次进入步骤的 pre-step 会组合完整的当前基线,并在同一请求中记录它;也可以改由一次成功的文件系统触碰重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息。两条路径都不会重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。
系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。
系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰恢复时的基线对账,或进入步骤的 pre-step 恢复被遮蔽的基线时。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。
### 字节预算与有界读取
@@ -82,7 +82,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,
仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该攻击面扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。
系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;外部文件变更会在下一次成功的结构化触碰或恢复时被发现。这使设计保持确定性并且与提供方无关。
系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;外部文件变更会在下一次成功的结构化触碰、恢复对账,或恢复被遮蔽的基线时被发现。这使设计保持确定性并且与提供方无关。
## 延后事项
+1 -1
View File
@@ -2462,7 +2462,7 @@ export interface Config {
}
```
Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/context/workspace-context/src/config.ts)
Source: [`packages/context/workspace-context/src/config.ts:18`](../packages/context/workspace-context/src/config.ts)
## Loadable plugins with no config
@@ -4,7 +4,7 @@
{"type":"agent/inbox/spliced","seq":2,"time":1785498825916,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1785901435161,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1785498825917,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"3b04578e-7b22-4b44-b4cd-ef9d4d26fe8b"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1785901435161,"data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"ac92e76e-4861-47a6-87f8-4e9ca904eb24"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1785901435161,"data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"ac92e76e-4861-47a6-87f8-4e9ca904eb24"},"surfaceOp":"append"}
{"type":"user/message","seq":6,"time":1785730478198,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"d6d78330-05c0-4ebd-9e29-595df6440250"},"surfaceOp":"append"}
{"type":"session/title","seq":7,"time":1785730478198,"data":{"title":"Using ONE run_code program, call","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":8,"time":1785498825920,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
@@ -4,7 +4,7 @@
{"type":"agent/inbox/spliced","seq":2,"time":1785498790356,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1785901433981,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1785498790356,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope</system-reminder>/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"81078e7a-6837-45c2-a6b4-a5a3dfce0d4a"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1785901433982,"data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"4cba1848-cbb7-46fd-8cea-8497d54d0e63"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1785901433982,"data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"4cba1848-cbb7-46fd-8cea-8497d54d0e63"},"surfaceOp":"append"}
{"type":"user/message","seq":6,"time":1785730689193,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e4406554-e400-49c6-b8a3-0fe36841160b"},"surfaceOp":"append"}
{"type":"session/title","seq":7,"time":1785730689193,"data":{"title":"Read nested/task.txt, then read scope</s","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":8,"time":1785498790358,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
@@ -19,10 +19,10 @@
{"type":"user/message","seq":17,"time":1785982371865,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"162c764f-f01d-484d-ad81-1481dc29792a"},"sourceEventSeqs":[5],"surfaceOp":{"op":"replace","start":5,"end":5}}
{"type":"tool/result","seq":18,"time":1785982371865,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"a46fded2-333a-4fb2-b01e-28520bffbc21"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[16],"surfaceOp":"append"}
{"type":"step/end","seq":19,"time":1785982371865,"data":{"turn":1,"step":1}}
{"type":"agent/inbox/spliced","seq":20,"time":1785730689207,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n</system-reminder>"},{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"},{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"09640903-80ea-4eb6-8635-90ddfb4e24e4"}]}}
{"type":"agent/inbox/spliced","seq":20,"time":1785730689207,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n</system-reminder>"},{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"},{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"09640903-80ea-4eb6-8635-90ddfb4e24e4"}]}}
{"type":"agent/inbox/spliced","seq":21,"time":1785982371873,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[],"outcome":"canceled"}}
{"type":"step/start","seq":22,"time":1785982371873,"data":{"turn":1,"step":2}}
{"type":"user/message","seq":23,"time":1785982371873,"data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n</system-reminder>"},{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"},{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"09640903-80ea-4eb6-8635-90ddfb4e24e4"},"surfaceOp":"append"}
{"type":"user/message","seq":23,"time":1785982371873,"data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n</system-reminder>"},{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"},{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"09640903-80ea-4eb6-8635-90ddfb4e24e4"},"surfaceOp":"append"}
{"type":"assistant/chunk","seq":24,"time":1785498790377,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":25,"time":1785498790377,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}}}
{"type":"assistant/chunk","seq":26,"time":1785498790378,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}}}}
@@ -0,0 +1,25 @@
/**
* Loader fixture that resumes the seeded workspace-context session.
* @module workspace-context-resume-agent
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-session'
/** Fixture plugin name. */
export const name = 'workspace-context-resume-agent'
/** Services that must exist before the fixture resumes its agent. */
export const inject = ['agents', 'agentLoop', 'sessionPersistence']
/**
* Resume the seeded session and bind its handle to this fixture's lifetime.
* @param ctx - settled agent and persistence services from the Loader tree.
* @returns after the resumed agent is published.
*/
export async function apply(ctx: Context): Promise<void> {
const handle = await ctx.agents.resume({
resumeSessionId: 'workspace-context-resume' as SessionId,
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
ctx.effect(() => () => handle.dispose(), 'workspace-context-resume-agent.handle')
}
@@ -0,0 +1 @@
{"type":"session","version":0,"id":"workspace-context-resume-replay","createdAt":1,"delegationDepth":0}
@@ -0,0 +1,11 @@
[
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "text" },
{ "type": "text-delta", "index": 0, "text": "RESUME_DONE" },
{ "type": "block-end", "index": 0, "block": { "type": "text", "text": "RESUME_DONE" } },
{ "type": "finish", "reason": { "kind": "stop" } }
]
}
]
@@ -0,0 +1,22 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nOld workspace instruction.\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"ba65bdb41810f4d0129129dcbd6cadcd643c069d"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"session/end-seed","seq":4,"time":0,"data":{}}
{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}
{"type":"turn/start","seq":6,"time":0,"data":{"turn":2}}
{"type":"agent/inbox/spliced","seq":7,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":8,"time":0,"data":{"turn":2,"step":1}}
{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"<system-reminder>\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"session/title","seq":11,"time":0,"data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}"},"reason":"initial"}}
{"type":"request/context","seq":13,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RESUME_DONE"}}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}}}
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":18,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[14,15,16,17],"surfaceOp":"append"}
{"type":"step/end","seq":19,"time":0,"data":{"turn":2,"step":1}}
{"type":"turn/end","seq":20,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}
@@ -0,0 +1,22 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: CLAUDE.md\n\nOld CLAUDE rule.\n\nInstructions from: AGENTS.md\n\nOld AGENTS rule.\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"CLAUDE.md\",\"AGENTS.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"b525eb8a6d3660b732dad4b0aff1b7c63ab32890"},{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"3113bd093ae91976207dcef7390bdc0b2bfcfa10"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"session/end-seed","seq":4,"time":0,"data":{}}
{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}
{"type":"turn/start","seq":6,"time":0,"data":{"turn":2}}
{"type":"agent/inbox/spliced","seq":7,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":8,"time":0,"data":{"turn":2,"step":1}}
{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"<system-reminder>\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"session/title","seq":11,"time":0,"data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}"},"reason":"initial"}}
{"type":"request/context","seq":13,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RESUME_DONE"}}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}}}
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":18,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[14,15,16,17],"surfaceOp":"append"}
{"type":"step/end","seq":19,"time":0,"data":{"turn":2,"step":1}}
{"type":"turn/end","seq":20,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}
@@ -0,0 +1,233 @@
/**
* Assembled-app regression for persisted workspace-instruction resume state.
* @module workspace-context-resume-snapshot
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, {
SESSION_FORMAT_VERSION,
SessionId,
type SessionEvent,
type SessionHeader,
} from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { renderWorkspaceContext } from '@deepseek-ai/dsh-workspace-context'
import { resolveConfig, workspaceBaselineIdentity } from '@deepseek-ai/dsh-workspace-context/src/config.ts'
import { describe, expect, it } from 'vitest'
const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit')
const replayFixture = join(fixtureDir, 'replay.jsonl')
const replayOverride = join(fixtureDir, 'replay.override.json')
const sessionExpected = join(fixtureDir, 'session.expected.jsonl')
const precedenceExpected = join(dirname(fixtureDir), 'precedence-change/session.expected.jsonl')
const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url))
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const sessionId = SessionId('workspace-context-resume')
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
const oldInstruction = 'Old workspace instruction.'
const newInstruction = 'New workspace instruction after offline edit.'
interface SeedBaselineOptions {
files?: Array<{ name: string; content: string }>
instructionFileCandidates?: string[]
}
async function seedVisibleBaseline(
root: string,
cwd: string,
options: SeedBaselineOptions = {},
): Promise<string> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
const meta: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: 1,
cwd,
delegationDepth: 0,
}
const files = options.files ?? [{ name: 'AGENTS.md', content: oldInstruction }]
const baseline = renderWorkspaceContext(files.map(file => ({
absolutePath: join(cwd, file.name),
displayPath: file.name,
content: file.content,
})), { maxBytes: 65536 })
const config = resolveConfig({
dshHome: join(cwd, '.dsh'),
maxBytes: 65536,
...options.instructionFileCandidates === undefined
? {}
: { instructionFileCandidates: options.instructionFileCandidates },
})
const events: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } },
{
type: 'user/message',
seq: 1,
time: 11,
data: createUserMessage({ content: [{ type: 'text', text: 'Remember the workspace instruction.' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{
type: 'user/message',
seq: 2,
time: 12,
data: createUserMessage({
content: [{ type: 'text', text: baseline.text }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
baselineIdentity: workspaceBaselineIdentity(config, cwd, cwd),
changes: files.map(file => ({
action: 'set',
scope: `.\0${file.name}`,
path: file.name,
digest: createHash('sha1').update(file.content).digest('hex'),
})),
},
}),
surfaceOp: 'append',
},
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } },
]
try {
await ctx.sessionPersistence.create(meta)
await ctx.sessionPersistence.append(sessionId, events)
const location = ctx.sessionPersistence.locate(meta)
if (location === undefined) throw new Error('JSONL backend did not locate the seeded session')
return location.path
} finally {
await ctx.fiber.dispose()
}
}
describe('workspace-context resume snapshot', () => {
it('appends an offline replacement without duplicating the visible baseline', async () => {
let cwd = ''
let sessionPath = ''
const result = await runLoaderSmoke({
label: 'workspace-context resume headless stream-json snapshot',
tempDirPrefix: 'dsh-workspace-context-resume-',
binScript,
configPath,
binArgs: ['--config', configPath, '--output-format', 'stream-json', 'Acknowledge the current workspace instruction.'],
tsconfigPath,
env: {
DSH_SNAPSHOT_FILE: replayFixture,
DSH_SNAPSHOT_OVERRIDE: replayOverride,
},
prepare: async (runCwd) => {
cwd = runCwd
await mkdir(join(runCwd, '.git'), { recursive: true })
await writeFile(join(runCwd, 'AGENTS.md'), `${newInstruction}\n`)
sessionPath = await seedVisibleBaseline(join(runCwd, '.sessions'), runCwd)
},
inspect: async () => {
const normalization: NormalizeContext = { sessionIds: [sessionId], cwd }
const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization))
if (refreshing) await writeFile(sessionExpected, session)
expect(session).toBe(await readFile(sessionExpected, 'utf8'))
const records = session.trimEnd().split('\n').map(line => JSON.parse(line) as {
type?: string
data?: {
source?: { kind?: string; baseline?: boolean; changes?: Array<Record<string, unknown>> }
content?: Array<{ type?: string; text?: string }>
}
})
const workspaceEvents = records.filter(record => record.type === 'user/message'
&& record.data?.source?.kind === 'workspace-instructions')
expect(workspaceEvents.filter(record => record.data?.source?.baseline === true)).toHaveLength(1)
expect(workspaceEvents.filter(record => record.data?.source?.baseline !== true)).toHaveLength(1)
expect(workspaceEvents.at(-1)?.data?.source?.changes).toMatchObject([{
action: 'replace', scope: '.\0AGENTS.md', path: 'AGENTS.md',
}])
expect(JSON.stringify(workspaceEvents.at(-1)?.data?.content)).toContain(newInstruction)
const files = await readdir(join(cwd, '.sessions'), { recursive: true })
expect(files.filter(file => file.endsWith('.jsonl'))).toHaveLength(1)
},
})
expect(result.stderr).toBe('')
const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
expect(records.at(-1)).toMatchObject({
type: 'result',
sessionId,
output: 'RESUME_DONE',
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('supersedes an incompatible baseline when precedence changed offline', async () => {
let cwd = ''
let sessionPath = ''
const result = await runLoaderSmoke({
label: 'workspace-context precedence-change resume snapshot',
tempDirPrefix: 'dsh-workspace-context-precedence-',
binScript,
configPath,
binArgs: ['--config', configPath, '--output-format', 'stream-json', 'Acknowledge the current workspace instruction.'],
tsconfigPath,
env: {
DSH_SNAPSHOT_FILE: replayFixture,
DSH_SNAPSHOT_OVERRIDE: replayOverride,
},
prepare: async (runCwd) => {
cwd = runCwd
await mkdir(join(runCwd, '.git'), { recursive: true })
await writeFile(join(runCwd, 'AGENTS.md'), 'Current AGENTS rule.\n')
await writeFile(join(runCwd, 'CLAUDE.md'), 'Current CLAUDE rule.\n')
sessionPath = await seedVisibleBaseline(join(runCwd, '.sessions'), runCwd, {
files: [
{ name: 'CLAUDE.md', content: 'Old CLAUDE rule.' },
{ name: 'AGENTS.md', content: 'Old AGENTS rule.' },
],
instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'],
})
},
inspect: async () => {
const normalization: NormalizeContext = { sessionIds: [sessionId], cwd }
const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization))
if (refreshing) {
await mkdir(dirname(precedenceExpected), { recursive: true })
await writeFile(precedenceExpected, session)
}
expect(session).toBe(await readFile(precedenceExpected, 'utf8'))
const records = session.trimEnd().split('\n').map(line => JSON.parse(line) as {
type?: string
data?: {
source?: { kind?: string; baseline?: boolean }
content?: Array<{ type?: string; text?: string }>
}
})
const baselines = records.filter(record => record.type === 'user/message'
&& record.data?.source?.kind === 'workspace-instructions'
&& record.data.source.baseline === true)
expect(baselines).toHaveLength(2)
const replacement = JSON.stringify(baselines.at(-1)?.data?.content)
expect(replacement).toContain('replaces all earlier workspace instruction baselines')
expect(replacement.indexOf('Instructions from: AGENTS.md'))
.toBeLessThan(replacement.indexOf('Instructions from: CLAUDE.md'))
},
})
expect(result.stderr).toBe('')
expect(result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>).at(-1))
.toMatchObject({
type: 'result',
sessionId,
output: 'RESUME_DONE',
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
@@ -0,0 +1,37 @@
# Keyless real-Loader composition for workspace-instruction resume
# reconciliation. The test seeds one persisted baseline, changes AGENTS.md
# while the session is offline, then resumes through the public agent service.
- id: persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: './.sessions'
compression: none
- id: replay
name: '@deepseek-ai/dsh-llm-replay'
config:
file: !!js process.env.DSH_SNAPSHOT_FILE
overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.cwd()
- id: agent
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
agents: []
workspaceContext:
maxBytes: 65536
dshHome: !!js process.cwd() + '/.dsh'
skills:
enabled: false
toolBash: false
toolTasks: false
goals: false
# Await the persisted resume before the headless driver inspects root agents.
- id: resumed-agent
name: './tests/fixtures/workspace-context-resume-agent.ts'
+1
View File
@@ -37,6 +37,7 @@
"headless-agent/tests/fixtures/cli-mock-llm.ts",
"headless-agent/tests/fixtures/semantic-checkpoint-agent.ts",
"headless-agent/tests/fixtures/subagent-inheritance-agent.ts",
"headless-agent/tests/fixtures/workspace-context-resume-agent.ts",
"headless-agent/tests/fixtures/goal-domain/seed-goal.ts",
"headless-agent/tests/fixtures/time-context-driver.ts",
"headless-agent/tests/fixtures/time-context-mock-llm.ts",
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md
README.md: b9751f2d0f64027d9c28c67971e6e5aa4e1278a1
README.zh.md: 0daabf0cd2b541a16b697e5bbe2a5a8582598f49
README.md: 82aee27a8fbd6a1ab0f0860226b081e28ba72e6e
README.zh.md: 9d983f95f4e018cb8fe983d9862cf5f31f83c5f2
+6 -6
View File
@@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
## Lifecycle
The first eligible `agent/pre-step` of each live session composes the baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so the direct prompt and the durable baseline enter step 1 and reach the first request together. A rejected or empty first-step decision leaves the baseline in the agent's `next-step` inbox for a later wakeup. The loader reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates.
The first eligible `agent/pre-step` of each live session composes the baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so the direct prompt and the durable baseline enter step 1 and reach the first request together. A rejected or empty first-step decision leaves the baseline in the agent's `next-step` inbox for a later wakeup. The loader reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates. A resumed session retains one compatible visible baseline and appends only current-file transitions; a changed discovery, precedence, project-root, or budget identity instead folds one explicitly superseding complete baseline into the entering batch.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
@@ -48,11 +48,11 @@ The plugin owns the complete `<system-reminder>` framing, and every injected `us
## State And Refresh
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step folds newly composed context into its final batch immediately after the claimed messages and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. If the owning `step/end` arrives before a matching dynamic context reaches the log, the plugin clears that pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step folds newly composed context into its final batch immediately after the claimed messages and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. If the owning `step/end` arrives before a matching dynamic context reaches the log, the plugin clears that pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache.
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. When compaction shadows the event, the next entering pre-step composes the current baseline and records it in the same request; a successful filesystem touch can instead re-add an unchanged baseline scope or append its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it queues a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes at its first pre-step; an entering first request records that context in the same step. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline.
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. When compaction shadows the event, the next entering pre-step composes the current baseline and records it in the same request; a successful filesystem touch can instead re-add an unchanged baseline scope or append its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. At the first pre-step after resume or hot remount, a compatible visible baseline is retained and compared with the files retained by the current complete rendering. Unchanged and budget-omitted files append nothing; offline additions, edits, removals, and files leaving the retained budget set append `set`, `replace`, or `remove` transitions. An incompatible visible baseline is superseded by one complete current baseline, including an explicit empty baseline when no candidate remains. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a resumed session reconciles its baseline, or when an entering pre-step restores a shadowed baseline.
## Configuration
@@ -83,7 +83,7 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even
#### What the model sees
At the first request of each loop instance, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order.
At the first request, derived history contains one durable user-role message with the bounded user-global and project instruction chain in broad-to-specific order. Resume reuses that message when its visible baseline is compatible.
##### Baseline instruction template
@@ -107,7 +107,7 @@ The rendered baseline is appended once and remains in derived history until comp
#### KV Cache effect
Append-only after the existing reusable prefix. A new or resumed instance may append a recomposed baseline, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position.
Append-only after the existing reusable prefix. Resume preserves reuse when the visible baseline identity is compatible; an incompatible identity appends a complete replacement, so discovery, precedence, project-root, or budget changes affect reuse only from that history position.
### Newly discovered scope context
@@ -162,7 +162,7 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop prepares its baseline.
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, when resume reconciles a visible baseline, or when an entering pre-step restores a shadowed baseline.
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration.
- **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it.
- **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories.
@@ -6,7 +6,7 @@
## 生命周期
每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使直接提示词与持久基线一同进入步骤 1,并共同抵达第一次请求。reject 或空的第一步决策会将基线留在 agent 的 `next-step` inbox,等待后续唤醒。loader 先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。
每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使直接提示词与持久基线一同进入步骤 1,并共同抵达第一次请求。reject 或空的第一步决策会将基线留在 agent 的 `next-step` inbox,等待后续唤醒。loader 先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。恢复后的会话会保留一条兼容的可见基线,并只追加当前文件的转换;如果发现、优先级、项目根目录或预算标识发生变化,则会将一条明确取代旧基线的完整基线折入进入步骤的批次。
该插件还会监听 `tools/post-execute` 中成功的第一方 `read``write``edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。
@@ -48,11 +48,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when
## 状态与刷新
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。如果所属 `step/end` 在匹配的动态上下文进入日志之前到达,插件会清除该 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。如果所属 `step/end` 在匹配的动态上下文进入日志之前到达,插件会清除该 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。当压缩遮蔽该事件时,下一次进入步骤的 pre-step 会组合当前基线,并在同一请求中记录它;也可以改由一次成功的文件系统 touch 重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会排队当前基线。恢复的 loop 始终在第一次 pre-step 重新组合当前基线,并对账仍可见的动态 scope;首次请求若进入步骤,就会在同一步骤记录该上下文。没有文件 watcher,因此磁盘变更会在下一次成功 `read``write``edit` touch 时可见,也会在恢复 loop 准备基线时可见。
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。当压缩遮蔽该事件时,下一次进入步骤的 pre-step 会组合当前基线,并在同一请求中记录它;也可以改由一次成功的文件系统 touch 重新添加未变的基线 scope,或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。恢复或插件热重挂后的第一次 pre-step 会保留兼容的可见基线,并将它与当前完整渲染所保留的文件进行比较。未变化和被预算省略的文件不追加任何内容;agent 离线期间新增、编辑、移除或不再属于预算保留集的文件会追加 `set``replace``remove` 转换。不兼容的可见基线会被一条完整的当前基线取代;如果没有候选文件,这条当前基线会是显式空基线。没有文件 watcher,因此磁盘变更会在下一次成功 `read``write``edit` touch 时可见,也会在恢复后的会话对账其基线时,或进入步骤的 pre-step 恢复被遮蔽的基线时可见。
## 配置
@@ -83,7 +83,7 @@ export interface Config {
#### 模型看到的内容
在每个 loop 实例的第一请求中,模型会收到一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。
第一请求的派生历史中包含一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。可见基线兼容时,恢复会复用该消息。
##### 基线指令模板
@@ -107,7 +107,7 @@ Instructions from: AGENTS.md
#### KV Cache 影响
仅追加,位于现有可复用前缀之后。新建或恢复的实例可能追加重新组合的基线,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。
仅追加,位于现有可复用前缀之后。可见基线标识兼容时,恢复会保持复用;不兼容的标识会追加一条完整的替代基线,因此发现、优先级、项目根目录或预算变更会从该历史位置起影响复用。
### 新发现的 scope 上下文
@@ -162,7 +162,7 @@ The previously loaded instructions from this file no longer apply.
## 已知限制与暂缓事项
- **发现跟随结构化 fs 工具,而非 shell 导航**:更改目录的 `bash` 命令不会触发嵌套指令发现,因为 shell 语法与每次调用 shell 状态不是可靠的文件系统 seam。
- **刷新由 touch 驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read``write``edit`可见,也会在恢复 loop 准备基线时可见。
- **刷新由 touch 驱动**:没有 watcher;外部编辑会在下一次成功的第一方 `read``write``edit`、恢复过程对账可见基线时,或进入步骤的 pre-step 恢复被遮蔽的基线时可见。
- **候选语义有意保持简单**:不解释小写名称、`.claude/rules/``@path` import;项目 scope 默认加载 `AGENTS.local.md``CLAUDE.local.md` overlay,但用户全局 `$DSH_HOME` scope 没有本地 overlay,其他自定义名称需要显式候选配置。
- **每目录去重基于内容**:只有在去除首尾空白后字节完全一致时,才折叠同级候选文件。`CLAUDE.md` 若 symlink 到同级 `AGENTS.md`,会解析为相同内容,并像任何重复项一样折叠;从 `AGENTS.md` 漂移的独立实体副本则会与它一起完整加载。
- **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低优先级的工作区指引(它绝不会覆盖 system、developer 或用户直接下达的指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`
@@ -4,6 +4,7 @@
* @module @deepseek-ai/dsh-workspace-context/config
*/
import { relative } from 'node:path'
import z from 'schemastery'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -58,6 +59,28 @@ export interface ResolvedConfig extends ResolvedDiscoveryConfig {
maxSourceBytes: number
}
/**
* Identify the discovery, precedence, and budget semantics of one baseline.
* @param config - normalized plugin configuration.
* @param cwd - absolute session working directory.
* @param projectRoot - project root selected for the current baseline.
* @returns stable serialized identity for compatibility checks on resume.
*/
export function workspaceBaselineIdentity(
config: ResolvedConfig,
cwd: string,
projectRoot: string,
): string {
return JSON.stringify({
projectRoot: relative(cwd, projectRoot),
projectRootMarkers: config.projectRootMarkers,
maxBytes: config.maxBytes,
maxSourceBytes: config.maxSourceBytes,
instructionFileCandidates: config.instructionFileCandidates,
localInstructionFileCandidates: config.localInstructionFileCandidates,
})
}
/**
* Resolve defaults, the harness home, and valid same-directory candidates.
* @param config - user-facing plugin configuration.
@@ -46,17 +46,22 @@ interface DiscoverOptions {
projectRootMarkers?: string[]
instructionFileCandidates?: string[]
localInstructionFileCandidates?: string[]
projectRoot?: string
signal?: AbortSignal
}
interface LoadOptions extends DiscoverOptions {
maxBytes: number
maxSourceBytes?: number
replacePreviousBaseline?: boolean
}
/** Rendered baseline plus the files that survived byte budgeting. */
/** Rendered baseline plus the successfully read and byte-budget-retained files. */
export interface RenderedInstructionSet {
rendered: RenderedWorkspaceContext
/** Successfully read candidates before content deduplication and byte budgeting. */
observed: LoadedInstructionFile[]
/** Candidates retained by content deduplication and byte budgeting. */
included: LoadedInstructionFile[]
}
@@ -286,7 +291,8 @@ async function discoverInstructionFiles(
}
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
const projectRoot = options.projectRoot
?? await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
for (const dir of ancestorChain(projectRoot, cwd)) {
for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) {
for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) {
@@ -389,7 +395,7 @@ export async function loadBaselineInstructions(
* Load a baseline together with the files retained after rendering.
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
* @param fileSystem - optional provider used instead of host filesystem reads.
* @returns rendered context and retained files, or undefined when empty or disabled.
* @returns rendered context and retained files, an explicit empty replacement set, or undefined when empty or disabled.
*/
export async function loadBaselineInstructionSet(
options: LoadOptions,
@@ -412,10 +418,29 @@ export async function loadBaselineInstructionSet(
}
}
const deduped = dedupInstructionFilesByDirectory(loaded)
if (deduped.length === 0) return undefined
const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes })
if (deduped.length === 0) {
if (options.replacePreviousBaseline !== true) return undefined
return {
rendered: renderWorkspaceContext([], {
maxBytes: config.maxBytes,
replacePreviousBaseline: true,
}),
observed: [],
included: [],
}
}
const rendered = renderWorkspaceContext(deduped, {
maxBytes: config.maxBytes,
...options.replacePreviousBaseline === undefined
? {}
: { replacePreviousBaseline: options.replacePreviousBaseline },
})
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) }
return {
rendered,
observed: loaded,
included: deduped.filter(file => !omitted.has(file.absolutePath)),
}
}
/**
+77 -17
View File
@@ -13,10 +13,10 @@ import type { Context } from 'cordis'
import { isDeepStrictEqual } from 'node:util'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
import { loadBaselineInstructionSet } from './files.ts'
import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'
import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'
import {
applyInstructionVersionUpdates,
baselineInstructionState,
@@ -24,6 +24,7 @@ import {
reconcileInstructionContext,
workspaceContextMessage,
type InstructionVersionCache,
type WorkspaceInstructionSource,
} from './state.ts'
import type { WorkspaceInstructionChange } from './render.ts'
@@ -39,13 +40,22 @@ export type {
export { renderWorkspaceContext } from './render.ts'
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
function hasVisibleBaseline(agent: Agent): boolean {
return agent.session.surface.nodes.some((seq) => {
function visibleBaselineSource(
agent: Agent,
authorityMessages: readonly UserMessage[],
): WorkspaceInstructionSource | undefined {
for (const message of authorityMessages.toReversed()) {
if (message.source.kind === 'workspace-instructions' && message.source.baseline === true) {
return message.source
}
}
for (const seq of agent.session.surface.nodes.toReversed()) {
const event = agent.session.events[seq]
return event?.type === 'user/message'
if (event?.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.baseline === true
})
&& event.data.source.baseline === true) return event.data.source
}
return undefined
}
function isWorkspaceContext(message: UserMessage): boolean {
@@ -70,6 +80,10 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = resolveConfig(config)
const instructionVersions: InstructionVersionCache = new WeakMap()
const baselinePreparations = new WeakMap<Session, {
identity: string
excludedScopes: ReadonlySet<string>
}>()
const projectionLifecycle = new AbortController()
ctx.effect(
() => () => {
@@ -99,11 +113,20 @@ export function apply(ctx: Context, config: Config): void {
const changes: WorkspaceInstructionChange[] = []
let desiredBaseline = false
const authorityMessages = [...claimed]
const baselinePresent = hasVisibleBaseline(agent) || claimed.some(message =>
message.source.kind === 'workspace-instructions' && message.source.baseline === true)
if (!baselinePresent) {
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, signal)
const identity = workspaceBaselineIdentity(resolved, cwd, projectRoot)
const visibleBaseline = visibleBaselineSource(agent, authorityMessages)
const baselinePresent = visibleBaseline !== undefined
const keepVisibleBaseline = visibleBaseline?.baselineIdentity === identity
const prepared = baselinePreparations.get(agent.session)
let excludedBaselineScopes = keepVisibleBaseline && prepared?.identity === identity
? prepared.excludedScopes
: undefined
let nextPreparation: { identity: string; excludedScopes: ReadonlySet<string> } | undefined
if (!baselinePresent || !keepVisibleBaseline || excludedBaselineScopes === undefined) {
const replacePreviousBaseline = baselinePresent && !keepVisibleBaseline
const instructions = await loadBaselineInstructionSet({
cwd,
dshHome: resolved.dshHome,
@@ -112,18 +135,45 @@ export function apply(ctx: Context, config: Config): void {
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
projectRoot,
replacePreviousBaseline,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
const observedBaseline = baselineInstructionState(instructions?.observed ?? [])
const excludedScopes = new Set(observedBaseline.changes.keys())
for (const scope of baseline.changes.keys()) excludedScopes.delete(scope)
excludedBaselineScopes = excludedScopes
nextPreparation = { identity, excludedScopes }
let versionStates = instructionVersions.get(agent.session)
if (versionStates === undefined && baseline.versions.size > 0) {
versionStates = new Map()
instructionVersions.set(agent.session, versionStates)
}
for (const [scope, state] of baseline.versions) versionStates?.set(scope, state)
if (instructions !== undefined && instructions.rendered.text.length > 0) {
content.push(...workspaceContextMessage(instructions.rendered.text).content)
changes.push(...baseline.changes.values())
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
const baselineContent = workspaceContextMessage(instructions.rendered.text).content
content.push(...baselineContent)
const replacementScopes = new Set(baseline.changes.keys())
const replacementRemovals = replacePreviousBaseline
? visibleBaseline.changes.flatMap(change => (
change.action === 'remove' || replacementScopes.has(change.scope)
? []
: [{ action: 'remove' as const, scope: change.scope, path: change.path }]
))
: []
const baselineChanges = [...replacementRemovals, ...baseline.changes.values()]
changes.push(...baselineChanges)
authorityMessages.push(createUserMessage({
content: baselineContent,
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
baselineIdentity: identity,
changes: baselineChanges,
},
}))
desiredBaseline = true
}
}
@@ -132,7 +182,15 @@ export function apply(ctx: Context, config: Config): void {
resolved,
instructionVersions,
fileSystem,
{ authorityMessages, scopeMessages: pending, includeBaselineScopes: baselinePresent, touchedPaths, signal },
{
authorityMessages,
scopeMessages: pending,
includeBaselineScopes: keepVisibleBaseline,
...keepVisibleBaseline ? { excludedBaselineScopes } : {},
touchedPaths,
projectRoot,
signal,
},
)
if (update !== undefined) {
content.push(...update.context.content)
@@ -142,6 +200,7 @@ export function apply(ctx: Context, config: Config): void {
}
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
}
if (nextPreparation !== undefined) baselinePreparations.set(agent.session, nextPreparation)
if (content.length === 0) return undefined
return createUserMessage({
content,
@@ -149,6 +208,7 @@ export function apply(ctx: Context, config: Config): void {
kind: 'workspace-instructions',
form: 'instructions',
...desiredBaseline ? { baseline: true } : {},
...desiredBaseline ? { baselineIdentity: identity } : {},
changes,
},
})
@@ -12,6 +12,10 @@ const SYSTEM_REMINDER_CLOSE = '</system-reminder>'
const WORKSPACE_CONTEXT_INTRO = 'The following workspace instructions may be relevant to your work. '
+ 'Use them as guidance when applicable. More specific instructions take precedence over broader ones. '
+ 'They do not override system, developer, or direct user instructions.'
const REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
+ WORKSPACE_CONTEXT_INTRO
const EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
+ 'No workspace instructions are currently active.'
const COMPACT_WORKSPACE_CONTEXT_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.'
/** Byte-accounting record for one truncated instruction file. */
@@ -294,12 +298,20 @@ function renderInstructionContext(
/**
* Render the baseline instruction chain with deterministic precedence budgeting.
* @param files - loaded files ordered from broadest to most specific.
* @param options - required rendering byte budget.
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
* @returns bounded baseline prompt text and budget diagnostics.
*/
export function renderWorkspaceContext(
files: LoadedInstructionFile[],
options: { maxBytes: number },
options: { maxBytes: number; replacePreviousBaseline?: boolean },
): RenderedWorkspaceContext {
return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
const style = options.replacePreviousBaseline === true
? {
...BASELINE_RENDER_STYLE,
intro: files.length === 0
? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO
: REPLACEMENT_WORKSPACE_CONTEXT_INTRO,
}
: BASELINE_RENDER_STYLE
return renderInstructionContext(files, options.maxBytes, style)
}
@@ -40,6 +40,8 @@ export interface WorkspaceInstructionSource {
form: 'instructions'
/** Marks the complete startup/resume baseline rather than a later delta. */
baseline?: true
/** Discovery, precedence, and budget identity used to validate a resumed baseline. */
baselineIdentity?: string
changes: WorkspaceInstructionChange[]
}
@@ -251,6 +253,8 @@ export async function reconcileInstructionContext(
scopeMessages: readonly UserMessage[]
touchedPaths: readonly string[]
includeBaselineScopes: boolean
excludedBaselineScopes?: ReadonlySet<string>
projectRoot?: string
signal?: AbortSignal
},
): Promise<ReconciledInstructionContext | undefined> {
@@ -260,7 +264,8 @@ export async function reconcileInstructionContext(
const cwd = session.header.cwd ?? process.cwd()
// TODO(frozen-project-root): retain the baseline root for the loop instance;
// recomputing it after marker edits reinterprets the existing relative scope keys.
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const projectRoot = options.projectRoot
?? await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const scopes = new Set<string>()
const baselineScopes = new Set<string>()
const addDirScopes = (target: Set<string>, directory: string): void => {
@@ -324,11 +329,23 @@ export async function reconcileInstructionContext(
else directoryScopes.push(scope)
}
for (const [directory, directoryScopes] of scopesByDirectory) {
const probedScopes: string[] = []
for (const scope of directoryScopes) {
if (options.excludedBaselineScopes !== undefined
&& baselineScopes.has(scope)
&& options.excludedBaselineScopes.has(scope)) {
const previous = effective.get(scope)
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
else pushRemoval(scope, previous.path)
} else {
probedScopes.push(scope)
}
}
const itemStart = items.length
const versionUpdateStart = versionUpdates.length
const addedAbsolutePaths: string[] = []
const priorVersions = new Map(directoryScopes.map(scope => [scope, versions.get(scope)]))
for (const scope of directoryScopes) {
const priorVersions = new Map(probedScopes.map(scope => [scope, versions.get(scope)]))
for (const scope of probedScopes) {
const previous = effective.get(scope)
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
if (probe.kind === 'unavailable') {
@@ -955,6 +955,267 @@ describe('workspace context request injection', () => {
}
})
it('retains one visible baseline across repeated session resumes', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root)
await composeBaselinePrefix(ctx, original)
const firstResume = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(ctx, firstResume)
const secondResume = stubAgent(root, [...firstResume.session.events])
await composeBaselinePrefix(ctx, secondResume)
expect(baselineEvents(firstResume)).toHaveLength(1)
expect(baselineEvents(secondResume)).toHaveLength(1)
expect(secondResume.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')).toHaveLength(1)
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('preserves a visible baseline when its source is unavailable during resume', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root)
await composeBaselinePrefix(ctx, original)
fs.throwOnStat.add(join(root, 'AGENTS.md'))
const resumed = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(ctx, resumed)
expect(baselineEvents(resumed)).toHaveLength(1)
expect(resumed.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')).toHaveLength(1)
} finally {
await ctx.fiber.dispose()
await rm(dirname(root), { recursive: true, force: true })
await rm(dirname(home), { recursive: true, force: true })
}
})
it('does not promote an unchanged budget-omitted baseline file during resume', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
const cwd = join(root, 'pkg')
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
await write(join(cwd, 'AGENTS.md'), 'package rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
const original = stubAgent(cwd)
await composeBaselinePrefix(ctx, original)
const firstResume = stubAgent(cwd, [...original.session.events])
await composeBaselinePrefix(ctx, firstResume)
const secondResume = stubAgent(cwd, [...firstResume.session.events])
await composeBaselinePrefix(ctx, secondResume)
expect(baselineEvents(secondResume)).toHaveLength(1)
expect(secondResume.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')).toHaveLength(1)
expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).toContain('omitted AGENTS.md')
expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).not.toContain('root root')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('removes a previously visible baseline file that leaves the retained budget set', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
const cwd = join(root, 'pkg')
await mkdir(join(root, '.git'), { recursive: true })
await mkdir(cwd, { recursive: true })
await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
const original = stubAgent(cwd)
await composeBaselinePrefix(ctx, original)
await write(join(cwd, 'AGENTS.md'), 'package rule')
const resumed = stubAgent(cwd, [...original.session.events])
await composeBaselinePrefix(ctx, resumed)
expect(baselineEvents(resumed)).toHaveLength(1)
const update = resumed.session.events.findLast(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.baseline !== true)
expect(update?.type === 'user/message' && update.data.source.kind === 'workspace-instructions'
? update.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') },
])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('recomposes the baseline when candidate precedence changes between resumes', async () => {
const root = await tempRepo()
const home = await tempRepo()
const originalCtx = new Context()
const resumedCtx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'agents rule')
await write(join(root, 'CLAUDE.md'), 'claude rule')
await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root)
await composeBaselinePrefix(originalCtx, original)
await mountWorkspaceContext(resumedCtx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'],
})
const resumed = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(resumedCtx, resumed)
const baselines = baselineEvents(resumed)
expect(baselines).toHaveLength(2)
const replacement = baselines.at(-1)
const replacementText = replacement?.type === 'user/message'
? blocksText(replacement.data.content)
: ''
expect(replacementText).toContain('replaces all earlier workspace instruction baselines')
expect(replacementText.indexOf('Instructions from: CLAUDE.md'))
.toBeLessThan(replacementText.indexOf('Instructions from: AGENTS.md'))
const baselineIdentities = baselines.flatMap(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& typeof event.data.source.baselineIdentity === 'string'
? [event.data.source.baselineIdentity]
: [])
expect(new Set(baselineIdentities).size).toBe(2)
const repeated = stubAgent(root, [...resumed.session.events])
await composeBaselinePrefix(resumedCtx, repeated)
expect(baselineEvents(repeated)).toHaveLength(2)
} finally {
await originalCtx.fiber.dispose()
await resumedCtx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('tombstones candidates removed across successive baseline configurations', async () => {
const root = await tempRepo()
const home = await tempRepo()
const agentsCtx = new Context()
const claudeCtx = new Context()
const restoredCtx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'agents rule')
await write(join(root, 'CLAUDE.md'), 'claude rule')
await mountWorkspaceContext(agentsCtx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['AGENTS.md'],
})
const original = stubAgent(root)
await composeBaselinePrefix(agentsCtx, original)
await mountWorkspaceContext(claudeCtx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['CLAUDE.md'],
})
const claudeResume = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(claudeCtx, claudeResume)
const claudeBaseline = baselineEvents(claudeResume).at(-1)
expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'workspace-instructions'
? claudeBaseline.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
{ action: 'set', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' },
])
await mountWorkspaceContext(restoredCtx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['AGENTS.md'],
})
const restored = stubAgent(root, [...claudeResume.session.events])
await composeBaselinePrefix(restoredCtx, restored)
const restoredBaseline = baselineEvents(restored).at(-1)
expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'workspace-instructions'
? restoredBaseline.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' },
{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
])
} finally {
await agentsCtx.fiber.dispose()
await claudeCtx.fiber.dispose()
await restoredCtx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('supersedes an incompatible visible baseline when no current candidate exists', async () => {
const root = await tempRepo()
const home = await tempRepo()
const originalCtx = new Context()
const resumedCtx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'agents rule')
await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root)
await composeBaselinePrefix(originalCtx, original)
await mountWorkspaceContext(resumedCtx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['POLICY.md'],
})
const resumed = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(resumedCtx, resumed)
const baselines = baselineEvents(resumed)
expect(baselines).toHaveLength(2)
const replacement = baselines.at(-1)
expect(replacement?.type === 'user/message' ? blocksText(replacement.data.content) : '')
.toContain('No workspace instructions are currently active.')
expect(replacement?.type === 'user/message' && replacement.data.source.kind === 'workspace-instructions'
? replacement.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
])
const repeated = stubAgent(root, [...resumed.session.events])
await composeBaselinePrefix(resumedCtx, repeated)
expect(baselineEvents(repeated)).toHaveLength(2)
} finally {
await originalCtx.fiber.dispose()
await resumedCtx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('reuses an inserted but unadmitted baseline after session recovery and plugin reload', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -1366,7 +1627,7 @@ describe('workspace context request injection', () => {
}
})
it('recomposes the baseline from current files when a resumed session edited it offline', async () => {
it('appends a replacement transition when a resumed session edited its baseline offline', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -1377,13 +1638,8 @@ describe('workspace context request injection', () => {
const original = stubAgent(root)
await composeBaselinePrefix(ctx, original)
// Offline edit to the baseline file, then resume on a fresh session whose
// seeded log already carries the original baseline. A resumed session is
// registered after this mount's apply(), so the remount guard never seeds
// it: its first step re-composes a fresh baseline from current files,
// reflecting the offline edit before the first resumed request. The old
// baseline stays in history unmutated (note: resume without mutating an
// earlier history event).
// The first resumed pre-step retains the compatible visible baseline and
// appends only the offline file transition needed to reach current state.
await write(join(root, 'AGENTS.md'), 'new root rule after offline edit')
const resumed = stubAgent(root, [...original.session.events])
@@ -1395,6 +1651,9 @@ describe('workspace context request injection', () => {
expect(baselines).toHaveLength(1)
const latest = resumed.session.events.findLast(event =>
event.type === 'user/message' && event.data.source.kind === 'workspace-instructions')
expect(latest?.type === 'user/message' ? latest.data.source : undefined).toMatchObject({
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(latest?.type === 'user/message' && blocksText(latest.data.content))
.toContain('new root rule after offline edit')
const original0 = baselines[0]
@@ -2799,6 +3058,80 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('ignores an unavailable scope whose visible state is already removed', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
const agent = stubAgent(root)
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'removed nested instructions' }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
},
}), { surfaceOp: 'append' })
const resolved = resolveConfig({
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['AGENTS.md'],
localInstructionFileCandidates: [],
})
const result = await reconcileInstructionContext(agent, resolved, new WeakMap(), fs, {
authorityMessages: [],
scopeMessages: [],
touchedPaths: [],
includeBaselineScopes: false,
signal: testToolSignal,
})
expect(result).toBeUndefined()
} finally {
await ctx.fiber.dispose()
await rm(dirname(root), { recursive: true, force: true })
await rm(dirname(home), { recursive: true, force: true })
}
})
it('loads one transition when user-global and project scopes resolve to the same file', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'shared rule' })
const agent = stubAgent(root)
const resolved = resolveConfig({
dshHome: root,
maxBytes: 65536,
instructionFileCandidates: ['AGENTS.md'],
localInstructionFileCandidates: [],
})
const result = await reconcileInstructionContext(agent, resolved, new WeakMap(), fs, {
authorityMessages: [],
scopeMessages: [],
touchedPaths: [],
includeBaselineScopes: true,
signal: testToolSignal,
})
expect(result?.context.source).toMatchObject({
changes: [{ action: 'set', scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE) }],
})
} finally {
await ctx.fiber.dispose()
await rm(dirname(root), { recursive: true, force: true })
}
})
it('removes a previously rendered sibling once its content becomes a duplicate of an earlier candidate', async () => {
const root = await tempRepo()
const home = await tempRepo()