Merge remote-tracking branch 'upstream/master' into fix/workspace-context-rendered-change-proof
# Conflicts: # .agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml # .agents/notes/implemented/feature/2026-06-24-workspace-context.md # .agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md # packages/context/workspace-context/README.i18n.yaml # packages/context/workspace-context/src/files.ts # packages/context/workspace-context/src/render.ts
This commit is contained in:
@@ -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: d4114b4895abcad500a5b06ac3ae3dd3add7344a
|
||||
README.zh.md: f598aa06ac2e5938f9017b4a1f6e4a9f5c7ac393
|
||||
README.md: 1ae47bef728a81c61f0db637872c93321a7bc687
|
||||
README.zh.md: 8087412057b3e5924764df179ae293db5699f9ae
|
||||
|
||||
@@ -6,9 +6,9 @@ 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.
|
||||
The plugin also observes immutable `tools/result` outcomes for successful first-party `read`, `write`, and `edit` calls. Each accepted 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 queues an addition in the agent inbox; a changed file queues a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate queues a removal notice. Native calls and Code Mode sub-dispatches share this path: nested touches bubble through opaque parent execution tokens until the top-level result settles, and touches produced inside an agent-loop step do not begin their asynchronous projection until the durable `step/end`. Direct tool executions outside an open step project immediately. This preserves tool-call/result/step adjacency without depending on filesystem timing. Discovery 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.
|
||||
|
||||
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It resolves each candidate and stats the result, so a final-component symlink is followed to its target: a link to a regular file loads that target's content, while a missing path or a non-file target (including a link to a directory) is a confirmed absence. A resolve or stat exception instead marks that candidate's scope temporarily unavailable. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
|
||||
|
||||
@@ -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 waits for every queued projection, 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. Nested results aggregate successful file touches under their parent execution token, including when a later composite result is blocked; the top-level result transfers those touches either to the currently open session step or directly to the per-agent projection queue. A `step/end` releases its staged touches only after that boundary is in durable history, and serialized projections reconcile against visible session events plus the current inbox before replacing the single pending workspace context.
|
||||
|
||||
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. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. A baseline may still publish its budget diagnostic with an empty change list. A dynamic batch with no committed change is not injected at all, and a later touch retries it.
|
||||
|
||||
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends 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
|
||||
|
||||
@@ -129,7 +129,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result.
|
||||
Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result and its enclosing durable step.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -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,9 +6,9 @@
|
||||
|
||||
## 生命周期
|
||||
|
||||
每个实时会话第一次符合条件的 `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,并共同抵达第一次请求。被拒绝或为空的第一步决策会将基线留在 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 语法也不可靠。
|
||||
该插件还会观察第一方 `read`、`write` 和 `edit` 调用成功后产生的不可变 `tools/result`。每个已接受的 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope:新出现的文件会在 agent inbox 中排入一项新增;已改变文件会排入一项替换;文件消失或成为同一目录中较早候选文件的重复项时,会排入一则移除通知。原生调用与 Code Mode 子分派共享该路径:嵌套 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定;在 agent loop(智能体循环)步骤内产生的 touch,须等持久 `step/end` 后才开始异步投影。打开的步骤之外直接执行工具时,则立即投影。这样无需依赖文件系统时序,也能保持工具调用/结果/步骤的相邻关系。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell,解析任意 shell 语法也不可靠。
|
||||
|
||||
指令读取使用可选 `ctx.fs` 提供方。该插件不会静态注入 `fs`,因此没有提供方的产品树仍可启动,指令加载在提供方出现前不执行任何操作。它会解析每个候选文件并对解析结果执行 stat,因此会跟随路径最后一段的 symlink 到其目标:指向常规文件的链接会加载目标内容,缺失路径或非文件目标(包括指向目录的链接)则已确认不存在。resolve 或 stat 异常会改为将该候选文件的 scope 标记为暂时不可用。前缀取消与动态工具取消会传播到解析、元数据探测与流式读取。文件加载后的提供方失败会视为暂时不可用,而非文件已删除的证据。
|
||||
|
||||
@@ -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 中仍待处理的副本;若被拒绝,当前上下文则继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。即使后续复合结果被拦截,成功的嵌套文件 touch 也会聚合到父级执行 token 下;顶层结果会将这些 touch 交给当前打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界进入持久历史后释放其暂存的 touch;串行投影会根据可见会话事件和当前 inbox 协调状态,再替换唯一一条待处理工作区上下文。
|
||||
|
||||
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1,也是每目录重复 key,因此较早候选文件与某个未更改文件的内容收敛后,后者仍可被移除。恢复可行,因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩(compaction)会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节,或原始内容确实为空时,才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来,部分截断就会记录完整内容的 digest;截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。基线即使带空变更列表,仍可发布字节预算诊断。动态批次若没有可提交变更,则完全不注入,并在后续 touch 时重试。
|
||||
|
||||
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 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 上下文
|
||||
|
||||
@@ -129,7 +129,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个已发现 scope 都会添加有界历史 token,直到压缩。可见会话状态与版本/digest 比较会抑制未更改内容,Code Mode 将同一消息延迟到外层 `run_code` 结果之后。
|
||||
每个已发现 scope 都会添加有界历史 token,直到压缩。可见会话状态与版本/digest 比较会抑制未更改内容,Code Mode 将同一消息延迟至外层 `run_code` 结果及其所属持久步骤之后。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -15,10 +15,9 @@ import { trimmedInstructionDigest } from './digest.ts'
|
||||
import {
|
||||
decodeScopeKey,
|
||||
renderWorkspaceInstructionSet,
|
||||
type RenderedWorkspaceContext,
|
||||
USER_GLOBAL_DIRECTORY,
|
||||
USER_GLOBAL_FILE,
|
||||
type RenderedInstructionSet,
|
||||
type RenderedWorkspaceContext,
|
||||
} from './render.ts'
|
||||
|
||||
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||
@@ -53,14 +52,24 @@ 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 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[]
|
||||
}
|
||||
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
|
||||
export type ScopeInstructionProbe =
|
||||
| { kind: 'present'; file: ProbedInstructionFile }
|
||||
@@ -287,7 +296,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)) {
|
||||
@@ -390,7 +400,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,
|
||||
@@ -413,8 +423,29 @@ export async function loadBaselineInstructionSet(
|
||||
}
|
||||
}
|
||||
const deduped = dedupInstructionFilesByDirectory(loaded)
|
||||
if (deduped.length === 0) return undefined
|
||||
return renderWorkspaceInstructionSet(deduped, { maxBytes: config.maxBytes })
|
||||
if (deduped.length === 0) {
|
||||
if (options.replacePreviousBaseline !== true) return undefined
|
||||
const { rendered, included } = renderWorkspaceInstructionSet([], {
|
||||
maxBytes: config.maxBytes,
|
||||
replacePreviousBaseline: true,
|
||||
})
|
||||
return {
|
||||
rendered,
|
||||
observed: [],
|
||||
included,
|
||||
}
|
||||
}
|
||||
const { rendered, included } = renderWorkspaceInstructionSet(deduped, {
|
||||
maxBytes: config.maxBytes,
|
||||
...options.replacePreviousBaseline === undefined
|
||||
? {}
|
||||
: { replacePreviousBaseline: options.replacePreviousBaseline },
|
||||
})
|
||||
return {
|
||||
rendered,
|
||||
observed: loaded,
|
||||
included,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
|
||||
import { loadBaselineInstructionSet } from './files.ts'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
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,16 +80,27 @@ 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()
|
||||
type ProjectionTouch = { agent: Agent; path: string }
|
||||
const executionTouches = new Map<ToolExecutionToken, ProjectionTouch[]>()
|
||||
ctx.effect(
|
||||
() => () => {
|
||||
projectionLifecycle.abort(new Error('workspace-context disposed'))
|
||||
executionTouches.clear()
|
||||
},
|
||||
'workspace-context.projectionLifecycle',
|
||||
)
|
||||
// Emit listeners are not awaited, so each projection must compose against the
|
||||
// inbox produced by earlier file results for the same agent.
|
||||
const projectionTails = new WeakMap<Agent, Promise<void>>()
|
||||
// Execution ancestry and the enclosing durable step are the two commit
|
||||
// boundaries before an asynchronous projection may mutate the agent inbox.
|
||||
const openSteps = new WeakMap<Session, boolean>()
|
||||
const stepTouches = new WeakMap<Session, ProjectionTouch[]>()
|
||||
|
||||
const compose = async (
|
||||
agent: Agent,
|
||||
@@ -99,11 +120,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 +142,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 +189,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 +207,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 +215,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
...desiredBaseline ? { baseline: true } : {},
|
||||
...desiredBaseline ? { baselineIdentity: identity } : {},
|
||||
changes,
|
||||
},
|
||||
})
|
||||
@@ -212,10 +279,48 @@ export function apply(ctx: Context, config: Config): void {
|
||||
while ((projection = projectionTails.get(agent)) !== undefined) await projection
|
||||
}
|
||||
|
||||
const stepIsOpen = (session: Session): boolean => {
|
||||
const known = openSteps.get(session)
|
||||
if (known !== undefined) return known
|
||||
let open = false
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'step/start') open = true
|
||||
else if (event.type === 'step/end' || event.type === 'turn/end') open = false
|
||||
}
|
||||
openSteps.set(session, open)
|
||||
return open
|
||||
}
|
||||
|
||||
const projectTouch = (touch: ProjectionTouch): void => {
|
||||
const session = touch.agent.session
|
||||
if (!stepIsOpen(session)) {
|
||||
queueProjection(touch.agent, touch.path)
|
||||
return
|
||||
}
|
||||
const pending = stepTouches.get(session)
|
||||
if (pending === undefined) stepTouches.set(session, [touch])
|
||||
else pending.push(touch)
|
||||
}
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type === 'step/start') {
|
||||
openSteps.set(session, true)
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
openSteps.set(session, false)
|
||||
return
|
||||
}
|
||||
if (event.type !== 'step/end') return
|
||||
openSteps.set(session, false)
|
||||
const pending = stepTouches.get(session)
|
||||
if (pending === undefined) return
|
||||
stepTouches.delete(session)
|
||||
for (const touch of pending) queueProjection(touch.agent, touch.path)
|
||||
})
|
||||
|
||||
ctx.on('agent/pre-step', async (
|
||||
agent: Agent,
|
||||
messages,
|
||||
{ step, signal },
|
||||
{ agent, messages, step, signal },
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
@@ -243,9 +348,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
|
||||
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
||||
if (result.isError || exec.agent === undefined || exec.signal.aborted) return
|
||||
const ownPath = filePathFromExecution(exec)
|
||||
if (ownPath === undefined) return
|
||||
queueProjection(exec.agent, ownPath)
|
||||
const touches = executionTouches.get(exec.token) ?? []
|
||||
executionTouches.delete(exec.token)
|
||||
if (!result.isError && exec.agent !== undefined && !exec.signal.aborted) {
|
||||
const ownPath = filePathFromExecution(exec)
|
||||
if (ownPath !== undefined) touches.push({ agent: exec.agent, path: ownPath })
|
||||
}
|
||||
if (exec.parent !== undefined) {
|
||||
if (touches.length > 0) {
|
||||
const parentTouches = executionTouches.get(exec.parent)
|
||||
if (parentTouches === undefined) executionTouches.set(exec.parent, touches)
|
||||
else parentTouches.push(...touches)
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const touch of touches) projectTouch(touch)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
@@ -39,15 +43,6 @@ interface RenderedInstructionContext extends RenderedWorkspaceContext {
|
||||
represented: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered baseline plus files whose section retained content, or whose original content was empty.
|
||||
* A partially rendered file keeps the digest of its complete original content.
|
||||
*/
|
||||
export interface RenderedInstructionSet {
|
||||
rendered: RenderedWorkspaceContext
|
||||
included: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
/** Structured dynamic state persisted outside model-visible prompt prose. */
|
||||
export interface WorkspaceInstructionChange {
|
||||
action: 'set' | 'replace' | 'remove'
|
||||
@@ -163,6 +158,16 @@ function additionalSectionText(file: LoadedInstructionFile): string {
|
||||
|
||||
const BASELINE_RENDER_STYLE: RenderStyle = { intro: WORKSPACE_CONTEXT_INTRO, section: sectionText }
|
||||
|
||||
function baselineRenderStyle(files: LoadedInstructionFile[], replacePreviousBaseline: boolean | undefined): RenderStyle {
|
||||
if (replacePreviousBaseline !== true) return BASELINE_RENDER_STYLE
|
||||
return {
|
||||
...BASELINE_RENDER_STYLE,
|
||||
intro: files.length === 0
|
||||
? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO
|
||||
: REPLACEMENT_WORKSPACE_CONTEXT_INTRO,
|
||||
}
|
||||
}
|
||||
|
||||
function changedSectionText(item: ChangeRenderItem): string {
|
||||
const { change, file } = item
|
||||
if (change.action === 'set') return additionalSectionText(file)
|
||||
@@ -329,27 +334,28 @@ function renderInstructionContext(
|
||||
/**
|
||||
* Render a baseline together with the exact source files semantically represented in it.
|
||||
* @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 public rendering plus files with surviving content, including genuinely empty files.
|
||||
* @internal
|
||||
*/
|
||||
export function renderWorkspaceInstructionSet(
|
||||
files: LoadedInstructionFile[],
|
||||
options: { maxBytes: number },
|
||||
): RenderedInstructionSet {
|
||||
const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
|
||||
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
||||
): { rendered: RenderedWorkspaceContext; included: LoadedInstructionFile[] } {
|
||||
const style = baselineRenderStyle(files, options.replacePreviousBaseline)
|
||||
const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, style)
|
||||
return { rendered, included: represented }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 renderWorkspaceInstructionSet(files, options).rendered
|
||||
}
|
||||
|
||||
@@ -33,13 +33,15 @@ import {
|
||||
|
||||
export const name = 'workspace-context'
|
||||
|
||||
/** Durable provenance and reconciliation facts for one workspace context. */
|
||||
/** Durable producer, file, and reconciliation facts for one workspace context. */
|
||||
export interface WorkspaceInstructionSource {
|
||||
kind: 'workspace-instructions'
|
||||
/** Every workspace context carries instructions read out of a file (the `instructions` context form). */
|
||||
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') {
|
||||
|
||||
@@ -57,7 +57,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
|
||||
@@ -75,6 +75,14 @@ class RecordingFileSystem extends FileSystem {
|
||||
return { targetKey: FsTargetKey(absolute), displayPath: absolute }
|
||||
}
|
||||
|
||||
override processPath(target: FsTarget): string { return String(target.targetKey) }
|
||||
|
||||
override fileUrl(target: FsTarget): string { return `file://${target.targetKey}` }
|
||||
|
||||
override contains(parent: FsTarget, child: FsTarget): boolean {
|
||||
return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`)
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
if (signal !== undefined) this.signals.push(signal)
|
||||
signal?.throwIfAborted()
|
||||
@@ -187,10 +195,16 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||||
}
|
||||
}
|
||||
|
||||
function stubToolExecution(input: Omit<ToolExecution, 'token'>): ToolExecution {
|
||||
function stubToolExecution(
|
||||
input: Omit<ToolExecution, 'token' | 'rootCallId'> & {
|
||||
token?: ToolExecutionToken
|
||||
rootCallId?: ToolExecution['rootCallId']
|
||||
},
|
||||
): ToolExecution {
|
||||
return {
|
||||
token: Symbol('workspace-context-test-execution') as ToolExecutionToken,
|
||||
token: input.token ?? Symbol('workspace-context-test-execution') as ToolExecutionToken,
|
||||
...input,
|
||||
rootCallId: input.rootCallId ?? input.callId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,8 +223,7 @@ async function workspaceContextOf(agent: Agent): Promise<UserMessage> {
|
||||
|
||||
async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise<void> {
|
||||
await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step', [],
|
||||
{ turn: 1, step: 1, signal: testToolSignal },
|
||||
'agent/pre-step', { messages: [], turn: 1, step: 1, signal: testToolSignal },
|
||||
async () => ({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
}
|
||||
@@ -245,15 +258,13 @@ async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Messag
|
||||
const signal = AbortSignal.timeout(1000)
|
||||
await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
[],
|
||||
{ turn: 1, step: 1, signal },
|
||||
{ messages: [], turn: 1, step: 1, signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
const claimed = agent.inbox.claim('next-step', 1)
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
claimed,
|
||||
{ turn: 1, step: 2, signal },
|
||||
{ messages: claimed, turn: 1, step: 2, signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
|
||||
)
|
||||
const entered = decision.kind === 'enter' ? decision.messages : []
|
||||
@@ -564,7 +575,7 @@ describe('workspace context instruction discovery', () => {
|
||||
const emptyHome = await tempRepo()
|
||||
// Isolate the default-home fallback: blank DSH_HOME is treated as unset, and
|
||||
// HOME points at an empty dir so the default ~/.dsh holds no global scope.
|
||||
// Symlinks are now followed, so a real ~/.dsh/AGENTS.md would otherwise leak in.
|
||||
// Symlinks are followed, so a real ~/.dsh/AGENTS.md would otherwise leak in.
|
||||
vi.stubEnv('DSH_HOME', '')
|
||||
vi.stubEnv('HOME', emptyHome)
|
||||
try {
|
||||
@@ -1055,6 +1066,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()
|
||||
@@ -1066,8 +1338,7 @@ describe('workspace context request injection', () => {
|
||||
const original = stubAgent(root)
|
||||
await agentEvents(ctx, original).waterfall(
|
||||
'agent/pre-step',
|
||||
[],
|
||||
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
const inserted = original.inbox.nextStep[0]
|
||||
@@ -1076,12 +1347,11 @@ describe('workspace context request injection', () => {
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const resumed = stubAgent(root, [...original.session.events])
|
||||
agentEvents(ctx, resumed).emit('agent/session-start', 'resume')
|
||||
agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
|
||||
const claimed = resumed.inbox.claim('next-step', 1)
|
||||
const decision = await agentEvents(ctx, resumed).waterfall(
|
||||
'agent/pre-step',
|
||||
claimed,
|
||||
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
{ messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
|
||||
)
|
||||
if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected')
|
||||
@@ -1113,8 +1383,7 @@ describe('workspace context request injection', () => {
|
||||
const original = stubAgent(root)
|
||||
await agentEvents(ctx, original).waterfall(
|
||||
'agent/pre-step',
|
||||
[],
|
||||
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
const stale = original.inbox.nextStep[0]
|
||||
@@ -1124,12 +1393,11 @@ describe('workspace context request injection', () => {
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const resumed = stubAgent(root, [...original.session.events])
|
||||
agentEvents(ctx, resumed).emit('agent/session-start', 'resume')
|
||||
agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
|
||||
const staleClaim = resumed.inbox.claim('next-step', 1)
|
||||
const staleDecision = await agentEvents(ctx, resumed).waterfall(
|
||||
'agent/pre-step',
|
||||
staleClaim,
|
||||
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
{ messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }),
|
||||
)
|
||||
|
||||
@@ -1168,8 +1436,7 @@ describe('workspace context request injection', () => {
|
||||
const original = stubAgent(root)
|
||||
await agentEvents(originalCtx, original).waterfall(
|
||||
'agent/pre-step',
|
||||
[],
|
||||
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
const stale = original.inbox.nextStep[0]
|
||||
@@ -1179,12 +1446,11 @@ describe('workspace context request injection', () => {
|
||||
if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes })
|
||||
const resumed = stubAgent(root, [...original.session.events])
|
||||
agentEvents(resumedCtx, resumed).emit('agent/session-start', 'resume')
|
||||
agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' })
|
||||
const claimed = resumed.inbox.claim('next-step', 1)
|
||||
const decision = await agentEvents(resumedCtx, resumed).waterfall(
|
||||
'agent/pre-step',
|
||||
claimed,
|
||||
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
{ messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
|
||||
)
|
||||
|
||||
@@ -1288,8 +1554,7 @@ describe('workspace context request injection', () => {
|
||||
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
[prompt],
|
||||
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
{ messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
() => Promise.resolve(downstream),
|
||||
)
|
||||
|
||||
@@ -1346,8 +1611,7 @@ describe('workspace context request injection', () => {
|
||||
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
[],
|
||||
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
() => Promise.resolve(downstream),
|
||||
)
|
||||
|
||||
@@ -1431,7 +1695,50 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('recomposes the baseline from current files when a resumed session edited it offline', async () => {
|
||||
it('folds a compacted baseline into the next entering pre-step before another filesystem touch', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'first post-compaction request rule')
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
const baseline = baselineEvents(agent)[0]
|
||||
expect(baseline).toBeDefined()
|
||||
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
|
||||
sourceEventSeqs: [baseline!.seq],
|
||||
})
|
||||
const prompt = createUserMessage({
|
||||
content: [{ type: 'text', text: 'continue after compaction' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
{ messages: [prompt], turn: 2, step: 1, signal: AbortSignal.timeout(1000) },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [prompt] }),
|
||||
)
|
||||
|
||||
if (decision.kind !== 'enter') throw new Error('post-compaction request was rejected')
|
||||
expect(decision.messages).toHaveLength(2)
|
||||
expect(decision.messages[0]).toBe(prompt)
|
||||
expect(decision.messages[1]?.source).toMatchObject({ kind: 'workspace-instructions', baseline: true })
|
||||
expect(blocksText(decision.messages[1]?.content)).toContain('first post-compaction request rule')
|
||||
expect(agent.inbox.nextStep).toEqual([])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('appends a replacement transition when a resumed session edited its baseline offline', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -1442,24 +1749,22 @@ 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])
|
||||
|
||||
// Resume announces its lifecycle start before the first step.
|
||||
agentEvents(ctx, resumed).emit('agent/session-start', 'resume')
|
||||
agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
|
||||
await composeBaselinePrefix(ctx, resumed)
|
||||
|
||||
const baselines = baselineEvents(resumed)
|
||||
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]
|
||||
@@ -1501,7 +1806,7 @@ describe('workspace context request injection', () => {
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => {
|
||||
ctx.on('agent/pre-step', async (_payload, next) => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject') return decision
|
||||
return {
|
||||
@@ -1784,8 +2089,7 @@ describe('workspace context request injection', () => {
|
||||
const reason = new Error('cancel prefix')
|
||||
const pending = agentEvents(ctx, stubAgent(root)).waterfall(
|
||||
'agent/pre-step',
|
||||
[],
|
||||
{ turn: 1, step: 1, signal: controller.signal },
|
||||
{ messages: [], turn: 1, step: 1, signal: controller.signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
|
||||
@@ -2874,6 +3178,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()
|
||||
@@ -3690,6 +4068,106 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('defers a nested file projection until the enclosing step commits', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
|
||||
const agent = stubAgent(root)
|
||||
const turnStart = agent.session.append('turn/start', { turn: 1 })
|
||||
ctx.emit('session/event', agent.session, turnStart)
|
||||
const stepStart = agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.emit('session/event', agent.session, stepStart)
|
||||
const outerToken = Symbol('outer-code-run') as ToolExecutionToken
|
||||
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
token: Symbol('nested-read') as ToolExecutionToken,
|
||||
parent: outerToken,
|
||||
signal: testToolSignal,
|
||||
callId: CallId('nested-read'),
|
||||
name: 'read',
|
||||
arguments: { file_path: join('pkg', 'file.txt') },
|
||||
agent,
|
||||
}), { content: [], isError: false, value: null })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
token: Symbol('nested-non-file') as ToolExecutionToken,
|
||||
parent: outerToken,
|
||||
signal: testToolSignal,
|
||||
callId: CallId('nested-non-file'),
|
||||
name: 'search',
|
||||
arguments: {},
|
||||
agent,
|
||||
}), { content: [], isError: false, value: null })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
token: Symbol('second-nested-read') as ToolExecutionToken,
|
||||
parent: outerToken,
|
||||
signal: testToolSignal,
|
||||
callId: CallId('second-nested-read'),
|
||||
name: 'read',
|
||||
arguments: { file_path: join('pkg', 'second.txt') },
|
||||
agent,
|
||||
}), { content: [], isError: false, value: null })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
token: outerToken,
|
||||
signal: testToolSignal,
|
||||
callId: CallId('outer-code-run'),
|
||||
name: 'run_code',
|
||||
arguments: {},
|
||||
agent,
|
||||
}), { content: [], isError: false, value: null })
|
||||
|
||||
await syncWorkspaceContext(ctx, agent)
|
||||
expect(agent.inbox.nextStep).toEqual([])
|
||||
|
||||
const stepEnd = agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
ctx.emit('session/event', agent.session, stepEnd)
|
||||
expect(blocksText((await syncedWorkspaceContext(ctx, agent)).content))
|
||||
.toContain('nested package rule')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('seeds closed step state from existing session history', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
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, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
|
||||
const agent = stubAgent(root)
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-closed-step'),
|
||||
name: 'read',
|
||||
arguments: { file_path: join('pkg', 'file.txt') },
|
||||
agent,
|
||||
}), { content: [], isError: false, value: null })
|
||||
|
||||
expect(blocksText((await syncedWorkspaceContext(ctx, agent)).content))
|
||||
.toContain('nested package rule')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores failed, aborted, agentless, and non-file final results', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
@@ -3739,17 +4217,18 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('warns when an asynchronous file-result projection fails', async () => {
|
||||
it('warns when an asynchronous file-result projection fails', { timeout: 20_000 }, async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
const agent = stubAgent('/')
|
||||
const root = resolve('/')
|
||||
const agent = stubAgent(root)
|
||||
const failure = new Error('projection failed')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
fs.entries.set('/.git', { type: 'directory' })
|
||||
fs.entries.set('/AGENTS.md', { type: 'file', content: 'workspace rule' })
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'workspace rule' })
|
||||
vi.spyOn(agent.inbox, 'prepend').mockImplementationOnce(() => { throw failure })
|
||||
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
@@ -3762,7 +4241,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(warn).toHaveBeenCalledWith('workspace instruction refresh failed: %o', failure)
|
||||
})
|
||||
}, { timeout: 10_000 })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
@@ -4017,8 +4496,7 @@ describe('workspace context inbox synchronization', () => {
|
||||
controller.abort(new Error('abort pre-step reconciliation'))
|
||||
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step', [],
|
||||
{ turn: 1, step: 1, signal: controller.signal },
|
||||
'agent/pre-step', { messages: [], turn: 1, step: 1, signal: controller.signal },
|
||||
async () => ({ kind: 'enter' as const, messages: [] }),
|
||||
)).rejects.toThrow('abort pre-step reconciliation')
|
||||
|
||||
@@ -4128,8 +4606,7 @@ describe('workspace context inbox synchronization', () => {
|
||||
const downstream = { kind: 'enter' as const, messages: claimed }
|
||||
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step', claimed,
|
||||
{ turn: 1, step: 1, signal: testToolSignal },
|
||||
'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: testToolSignal },
|
||||
async () => downstream,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user