From 207c45d15f5486bcf958a0c01c0ea5bab8192a49 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 19:55:27 +0800 Subject: [PATCH 1/7] fix(workspace-context): deduplicate baseline on resume --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 10 +- .../2026-06-24-workspace-context.zh.md | 10 +- docs/event-producer-consumer.md | 2 +- .../workspace-context-resume-agent.ts | 25 +++ .../offline-edit/replay.jsonl | 1 + .../offline-edit/replay.override.json | 11 ++ .../offline-edit/session.expected.jsonl | 20 +++ .../workspace-context-resume.snapshot.ts | 151 ++++++++++++++++++ ...rkspace-context-resume.cordis.snapshot.yml | 37 +++++ .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 10 +- .../context/workspace-context/README.zh.md | 10 +- .../context/workspace-context/src/index.ts | 14 +- .../context/workspace-context/src/state.ts | 2 +- .../tests/workspace-context.spec.ts | 100 ++++++++++-- 16 files changed, 358 insertions(+), 53 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts create mode 100644 examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.jsonl create mode 100644 examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.override.json create mode 100644 examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl create mode 100644 examples/headless-agent/tests/workspace-context-resume.snapshot.ts create mode 100644 examples/headless-agent/workspace-context-resume.cordis.snapshot.yml diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 2199cc6f6b..c127e30f7c 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -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: d58dde38b9851bc1cad2e6b4c679343dedfbc139 -2026-06-24-workspace-context.zh.md: 983b6a82df78cb865cf9db367cba0fd839c247e9 +2026-06-24-workspace-context.md: a9d11f88ab9525a40f9bc58f817a088edc43105a +2026-06-24-workspace-context.zh.md: 1273bd9b460055a4b0e193267c5e9ad37bbeb0a2 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index d58dde38b9..a9d11f88ab 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -28,11 +28,11 @@ The user-global file is fixed at `$DSH_HOME/AGENTS.md`, is not affected by eithe ### Baseline Injection -At the first `agent/step` of an agent-loop instance, the plugin injects one sourced user-role message before the request is derived. It 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 one candidate 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. +At the first `agent/step` of a fresh session, the plugin injects one sourced user-role message before the request is derived. It 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 one candidate 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 injection 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. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. +The injection becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes a complete baseline from later deltas, and its change list persists the included scopes and content digests. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. -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 remount injects a current baseline. A resume always re-composes. +A resumed agent creates a new loop instance over persisted history. If a typed baseline remains in the visible surface, the loop retains that event and reconciles baseline plus dynamic scopes against current files before its first request. Unchanged files append nothing; files added, edited, or removed while the agent was offline append `set`, `replace`, or `remove` transitions without mutating or duplicating the original baseline. A hot plugin remount follows the same visibility rule. If no typed baseline remains visible, as after compaction shadows it, the loop composes and injects one complete current baseline. Compaction can shadow the baseline after this plugin's guarded `agent/step` listener has already run for the session. The `system-prompt/assemble` waterfall therefore delegates first, but restores only for an assembly explicitly marked for the loop's next model request; diagnostic assemblies remain read-only. When a prior typed baseline exists but none remains visible, the listener recomposes the current chain, rechecks cancellation and the current surface generation after every asynchronous probe, and injects before the loop drains its outbox and snapshots derived request history. A per-session settled marker prevents repeated preparation when the current generation produced no baseline; a separate queued marker plus the synchronous commit-time recheck lets concurrent preparations scan without queuing duplicate baselines. @@ -54,9 +54,9 @@ Every workspace context event stores versioned metadata with `{ action, scope, p 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 a dynamic instruction event from the visible surface, that state no longer suppresses a later tool-triggered load; if it removes the baseline, prompt assembly restores the complete current chain before the next request. 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 visible baseline is comparison state for current-file reconciliation rather than a reason to append another complete baseline. If compaction removes a dynamic instruction event from the visible surface, that state no longer suppresses a later tool-triggered load; if it removes the baseline, prompt assembly restores the complete current chain before the next request. 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. Model-request prompt assembly recomposes a shadowed baseline for the current replacement generation and appends it before the first post-replacement request. It rechecks the caller's signal before injection, so an aborted preparation publishes no pending baseline; a queued marker remains until the corresponding durable event confirms delivery. Later successful filesystem touches can append edits or removals as dynamic messages. The plugin never 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 or post-replacement baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the next request. +The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A resumed loop retains that baseline and reconciles current baseline and visible dynamic scopes, so changes made while the agent was offline append transitions before the next request. Model-request prompt assembly instead recomposes a shadowed baseline for the current replacement generation and appends it before the first post-replacement request. It rechecks the caller's signal before injection, so an aborted preparation publishes no pending baseline; a queued marker remains until the corresponding durable event confirms delivery. The plugin never 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, post-replacement prompt assembly, or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 983b6a82df..1273bd9b46 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -28,11 +28,11 @@ Status: implemented ### 基线注入 -在 agent loop(智能体循环)实例的第一个 `agent/step`,插件会在派生请求前注入一条带来源的 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 +在全新会话的第一个 `agent/step`,插件会在派生请求前注入一条带来源的 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 -该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整的启动或恢复基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 +该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 -恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 +恢复 agent 会基于持久化历史创建新的 loop 实例。如果带类型的基线仍位于可见表层,loop 会保留该事件,并在第一个请求前根据当前文件对账基线与动态 scope。未变文件不追加任何内容;agent 离线期间新增、编辑或移除的文件会追加 `set`、`replace` 或 `remove` 转换,既不改写也不重复追加原始基线。插件热重挂遵循相同的可见性规则。如果已无带类型的基线可见(例如压缩(compaction)将其遮蔽后),loop 会组合并注入一条完整的当前基线。 在本插件带防护的 `agent/step` 监听器已经为该会话运行后,压缩仍可能遮蔽基线。因此,`system-prompt/assemble` waterfall(瀑布式事件)会先委托,但只有当组装被明确标记为供 loop 的下一个模型请求使用时才恢复;诊断组装保持只读。如果此前存在带类型的基线、但已无基线可见,该监听器会重新组合当前文件链,在每次异步探测后重新检查取消状态和当前表层代次,并在 loop 排空 outbox 和对派生请求历史创建快照之前注入。逐会话的已结算标记会在当前代次没有产生基线时避免重复准备;单独的排队标记加上提交时同步复查,使并发准备可以扫描而不会排入重复基线。 @@ -54,9 +54,9 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 -路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除动态指令事件,该状态不再抑制之后由工具触发的加载;如果移除的是基线,提示词组装会在下一个请求前恢复完整的当前指令链。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复从持久化元数据继续工作:可见基线是当前文件对账的比较状态,而不是追加另一条完整基线的理由。如果压缩从可见表面移除动态指令事件,该状态不再抑制之后由工具触发的加载;如果移除的是基线,提示词组装会在下一个请求前恢复完整的当前指令链。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 -只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。面向模型请求的提示词组装会为当前替换代次重新组合被遮蔽的基线,并在替换后的第一个请求前追加它。它会在注入前重新检查调用方的 signal,因此已中止的准备不会发布待处理基线;排队标记会保留,直到相应的持久事件确认投递。之后成功的文件系统触碰仍可把编辑或移除作为动态消息追加。插件绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。在恢复或替换后准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在下一个请求前追加更新。 +只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。恢复的 loop 会保留该基线,并对账当前基线与可见动态 scope,因此 agent 离线期间的变更会在下一个请求前追加为转换。而面向模型请求的提示词组装会为当前替换代次重新组合被遮蔽的基线,并在替换后的第一个请求前追加它。它会在注入前重新检查调用方的 signal,因此已中止的准备不会发布待处理基线;排队标记会保留,直到相应的持久事件确认投递。插件绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。 系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰、替换后的提示词组装或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ce019cfe9f..26db842db8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -19,7 +19,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:408`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | | `agent/step` | `serial` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts b/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts new file mode 100644 index 0000000000..4cdf7d157a --- /dev/null +++ b/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts @@ -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 { + 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') +} diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.jsonl new file mode 100644 index 0000000000..84501283f2 --- /dev/null +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"workspace-context-resume-replay","createdAt":1,"delegationDepth":0} diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.override.json b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.override.json new file mode 100644 index 0000000000..1a431e6dcc --- /dev/null +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/replay.override.json @@ -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" } } + ] + } +] diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl new file mode 100644 index 0000000000..9abcd67f44 --- /dev/null +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl @@ -0,0 +1,20 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"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":"\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"}],"source":{"kind":"workspace-instructions","baseline":true,"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":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":0,"data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"\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"}],"source":{"kind":"workspace-instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":9,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RESUME_DONE"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"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":[12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":18,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts new file mode 100644 index 0000000000..1d1c03b71e --- /dev/null +++ b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts @@ -0,0 +1,151 @@ +/** + * 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 { 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 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.' + +async function seedVisibleBaseline(root: string, cwd: string): Promise { + 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 baseline = renderWorkspaceContext([{ + absolutePath: join(cwd, 'AGENTS.md'), + displayPath: 'AGENTS.md', + content: oldInstruction, + }], { maxBytes: 65536 }) + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + 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', + baseline: true, + changes: [{ + action: 'set', + scope: '.\0AGENTS.md', + path: 'AGENTS.md', + digest: createHash('sha1').update(oldInstruction).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> } + 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) + expect(records.at(-1)).toMatchObject({ + type: 'result', + success: true, + sessionId, + result: 'RESUME_DONE', + reason: { kind: 'completed' }, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/headless-agent/workspace-context-resume.cordis.snapshot.yml b/examples/headless-agent/workspace-context-resume.cordis.snapshot.yml new file mode 100644 index 0000000000..f5c04d9093 --- /dev/null +++ b/examples/headless-agent/workspace-context-resume.cordis.snapshot.yml @@ -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' diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 00f13a4d0b..478bf7e052 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.i18n.yaml @@ -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: c4a3fae337cca70c98026bbdce7b252ba33bac0c -README.zh.md: 35b7fe6bfc7d247ff155d19053e3d9e4551838ae +README.md: 79f913a1cbabbcf474c5befccf01fb6eae76e843 +README.zh.md: 02f88c4e339b6a816b49db1eb9f91a440df787df diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index c4a3fae337..79f913a1cb 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The ## Lifecycle -The baseline is injected at the first `agent/step` of each live session. It 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. The durable sourced `user/message` enters the same request as the claimed prompt. If a later surface replacement such as compaction shadows that baseline, a model-request `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots that request; inspection-only assemblies do not mutate the session. +A complete baseline is injected at the first `agent/step` of a fresh session. It 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. The durable sourced `user/message` enters the same request as the claimed prompt. A resumed loop retains that baseline while it remains visible and appends only current-file transitions. If a later surface replacement such as compaction shadows the baseline, a model-request `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots that request; inspection-only assemblies do not mutate the session. 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 `` 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`. 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. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the 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`. 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. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the 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. After a surface replacement removes it, model-request prompt assembly recomposes the current baseline and rechecks cancellation, visibility, and the current replacement generation immediately before injecting it. Concurrent preparations can read in parallel, but only the first commit queues a baseline; inspection-only assemblies never restore one. A successful filesystem touch can still append later replacements or removals. 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 injects a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes before its first request. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a model request restores a shadowed baseline, 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. A resumed loop or hot plugin remount retains that one visible baseline and reconciles its baseline and dynamic scopes against current files before the first request: unchanged files append nothing, while offline additions, edits, and removals append typed `set`, `replace`, and `remove` transitions. If no typed baseline remains visible, as after a surface replacement, model-request prompt assembly recomposes the complete current baseline and rechecks cancellation, visibility, and the current replacement generation immediately before injecting it. Concurrent preparations can read in parallel, but only the first commit queues a baseline; inspection-only assemblies never restore one. The in-memory scope marker and provider-version cache only select and accelerate probes. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a model request restores a shadowed baseline, or when a resumed loop prepares its 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, and again on the first request after a surface replacement shadows it, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order. +A fresh session's first request contains one durable user-role message with the bounded user-global and project instruction chain in broad-to-specific order. A resumed request retains that message while it remains visible and adds only detected transitions; the first request after a surface replacement shadows it receives one recomposed complete baseline. ##### Baseline instruction template @@ -107,7 +107,7 @@ The rendered baseline remains in derived history until a surface replacement sha #### KV Cache effect -Append-only after the existing reusable prefix. A new, resumed, or post-compaction request 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. A fresh or post-compaction request may append a complete baseline; a resumed request retains its visible baseline and appends only detected transitions, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position. ### Newly discovered scope context diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index 35b7fe6bfc..02f88c4e33 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -6,7 +6,7 @@ ## 生命周期 -基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;仅检查组装不会改变会话。 +完整基线会在全新会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。恢复的 loop 会在该基线仍可见时保留它,只追加根据当前文件检测到的转换。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;仅检查组装不会改变会话。 该插件还会监听 `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`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 +模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `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 只会导致一次确认读取。压缩会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 -初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。表层替换将其移除后,面向模型请求的提示词组装会重新组合当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。成功的文件系统 touch 仍可在之后追加替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 +初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。恢复的 loop 或插件热重挂会保留这一条可见基线,并在第一个请求前根据当前文件对账其基线和动态 scope:未变文件不追加任何内容,而 agent 离线期间新增、编辑或移除的文件会追加带类型的 `set`、`replace` 或 `remove` 转换。如果已无带类型的基线可见(例如表层替换后),面向模型请求的提示词组装会重新组合完整的当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 ## 配置 @@ -83,7 +83,7 @@ export interface Config { #### 模型看到的内容 -在每个 loop 实例的第一个请求中,以及表层替换将其遮蔽后的第一个请求中,模型都会收到一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。 +全新会话的第一个请求包含一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。恢复后的请求会在该消息仍可见时保留它,并只追加检测到的转换;表层替换将其遮蔽后的第一个请求会收到一条重新组合的完整基线。 ##### 基线指令模板 @@ -107,7 +107,7 @@ Instructions from: AGENTS.md #### KV Cache 影响 -仅追加,位于现有可复用前缀之后。新建实例的请求、恢复后的请求或压缩后的请求可能追加重新组合的基线,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。 +仅追加,位于现有可复用前缀之后。全新请求或压缩后的请求可能追加完整基线;恢复后的请求保留其可见基线,只追加检测到的转换,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。 ### 新发现的 scope 上下文 diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 7ca9189a4f..ad1cade619 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -72,22 +72,12 @@ export function apply(ctx: Context, config: Config): void { // interval before an injected baseline becomes a durable surface event. const baselineSettledGeneration = new WeakMap() const baselineQueuedGeneration = new WeakMap() - // Sessions whose lifecycle start this mount witnessed. A startup or resume - // emits agent/session-start before the first step; a hot remount attaches to - // an already-live session and never sees it. Resumes always re-compose the - // baseline from current files. Hot remounts retain a baseline only while its - // typed event remains model-visible. - const lifecycleWitnessed = new WeakSet() const pendingByParent = new Map() - ctx.on('agent/session-start', (agent: Agent) => { - lifecycleWitnessed.add(agent.session) - }) - ctx.on('session/event', (session, event) => { observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions) if (event.type === 'user/message' @@ -136,7 +126,7 @@ export function apply(ctx: Context, config: Config): void { pendingNestedChanges, instructionVersions, fileSystem, - { includeBaselineScopes: false, ...signal === undefined ? {} : { signal } }, + { includeBaselineScopes: keepVisibleBaseline, ...signal === undefined ? {} : { signal } }, ) signal?.throwIfAborted() const generation = agent.session.surface.replaceGeneration @@ -175,7 +165,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { if (baselineLoaded.has(agent.session)) return - const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent.session) + const keepVisibleBaseline = hasVisibleBaseline(agent.session) await prepareBaseline(agent, signal, keepVisibleBaseline) }) diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 7bb24a43b2..383c765719 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -39,7 +39,7 @@ const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit']) /** Durable provenance and reconciliation facts for one workspace context. */ export interface WorkspaceInstructionSource { kind: 'workspace-instructions' - /** Marks the complete startup/resume baseline rather than a later delta. */ + /** Marks a complete baseline rather than a later delta. */ baseline?: true changes: WorkspaceInstructionChange[] } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 4d46e1393e..ed57a16497 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1035,6 +1035,34 @@ 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]) + agentEvents(ctx, firstResume).emit('agent/session-start', 'resume') + await composeBaselinePrefix(ctx, firstResume) + const secondResume = stubAgent(root, [...firstResume.session.events]) + agentEvents(ctx, secondResume).emit('agent/session-start', 'resume') + 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('retains a visible baseline after a plugin remount', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1307,7 +1335,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 without duplicating the baseline when a resumed session edited it offline', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1318,28 +1346,70 @@ 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). 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') await composeBaselinePrefix(ctx, resumed) const baselines = baselineEvents(resumed) - expect(baselines).toHaveLength(2) - const latest = baselines.at(-1) - expect(latest?.type === 'user/message' && blocksText(latest.data.content)) - .toContain('new root rule after offline edit') - const original0 = baselines[0] - expect(original0?.type === 'user/message' && blocksText(original0.data.content)) + expect(baselines).toHaveLength(1) + expect(baselines[0]?.type === 'user/message' && blocksText(baselines[0].data.content)) .toContain('old root rule') + 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).toMatchObject({ + changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], + }) + expect(update?.type === 'user/message' && blocksText(update.data.content)) + .toContain('new root rule after offline edit') + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + + it.each([ + { + name: 'adds a newly applicable baseline file', + action: 'set', + prepare: (root: string): Promise => write(join(root, 'pkg/AGENTS.md'), 'new package rule'), + scope: sk('pkg', 'AGENTS.md'), + path: join('pkg', 'AGENTS.md'), + }, + { + name: 'removes a deleted baseline file', + action: 'remove', + prepare: (root: string): Promise => rm(join(root, 'AGENTS.md')), + scope: sk('.', 'AGENTS.md'), + path: 'AGENTS.md', + }, + ])('$name during resume without duplicating the visible baseline', async ({ action, prepare, scope, path }) => { + 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 rule') + const ctx = new Context() + await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(cwd) + await composeBaselinePrefix(ctx, original) + + await prepare(root) + const resumed = stubAgent(cwd, [...original.session.events]) + agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + 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).toMatchObject({ + changes: [{ action, scope, path }], + }) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) From 3da48d174168cb48bf1630fe1c61fc31d67087e0 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 20:01:04 +0800 Subject: [PATCH 2/7] fix(ci): register workspace resume fixture --- knip.json | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.json b/knip.json index 4bb84659f2..90ce89880e 100644 --- a/knip.json +++ b/knip.json @@ -35,6 +35,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", From 01fa4ceb6e44818b6146991332cef6853218a319 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 20:12:32 +0800 Subject: [PATCH 3/7] test(workspace-context): cover pre-resume tool reconciliation --- .../tests/workspace-context.spec.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index ed57a16497..ed8f36c11c 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1063,6 +1063,35 @@ describe('workspace context request injection', () => { } }) + it('ignores a restored baseline during a file tool call before resume reconciliation', 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') + await write(join(root, 'file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const original = stubAgent(root) + await composeBaselinePrefix(ctx, original) + + const resumed = stubAgent(root, [...original.session.events]) + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-before-resume-reconciliation'), + name: 'read', + arguments: { file_path: 'file.txt' }, + agent: resumed, + }) + + expect(workspaceContextOf(result)).toBeUndefined() + expect(baselineEvents(resumed)).toHaveLength(1) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('retains a visible baseline after a plugin remount', async () => { const root = await tempRepo() const home = await tempRepo() From 119c55e35eb6fff4d760029599c717a7fd23a099 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 23:11:15 +0800 Subject: [PATCH 4/7] fix(workspace-context): reconcile resumed baselines --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 4 +- .../2026-06-24-workspace-context.zh.md | 4 +- docs/config-catalog.md | 2 +- .../offline-edit/session.expected.jsonl | 2 +- .../precedence-change/session.expected.jsonl | 20 ++ .../workspace-context-resume.snapshot.ts | 107 +++++++++- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 8 +- .../context/workspace-context/README.zh.md | 8 +- .../context/workspace-context/src/config.ts | 23 ++ .../context/workspace-context/src/files.ts | 25 ++- .../context/workspace-context/src/index.ts | 61 ++++-- .../context/workspace-context/src/render.ts | 18 +- .../context/workspace-context/src/state.ts | 22 +- .../tests/workspace-context.spec.ts | 202 ++++++++++++++++++ 16 files changed, 463 insertions(+), 51 deletions(-) create mode 100644 examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index c127e30f7c..f0c2829e02 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -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: a9d11f88ab9525a40f9bc58f817a088edc43105a -2026-06-24-workspace-context.zh.md: 1273bd9b460055a4b0e193267c5e9ad37bbeb0a2 +2026-06-24-workspace-context.md: 6fb739bf9a3bd508859c7ba4df499c847eba0570 +2026-06-24-workspace-context.zh.md: 51e43a4f2fcfa61973a9cc025a2c37818eb56bd1 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index a9d11f88ab..6fb739bf9a 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -32,7 +32,7 @@ At the first `agent/step` of a fresh session, the plugin injects one sourced use The injection becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes a complete baseline from later deltas, and its change list persists the included scopes and content digests. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. -A resumed agent creates a new loop instance over persisted history. If a typed baseline remains in the visible surface, the loop retains that event and reconciles baseline plus dynamic scopes against current files before its first request. Unchanged files append nothing; files added, edited, or removed while the agent was offline append `set`, `replace`, or `remove` transitions without mutating or duplicating the original baseline. A hot plugin remount follows the same visibility rule. If no typed baseline remains visible, as after compaction shadows it, the loop composes and injects one complete current baseline. +A resumed agent creates a new loop instance over persisted history. If a typed baseline remains in the visible surface and its persisted discovery, precedence, and budget identity matches current configuration, the loop retains that event and reconciles dynamic scopes plus the baseline files retained by the current complete rendering before its first request. Unchanged files and budget-omitted files append nothing; files added, edited, removed, or dropped from the retained set while the agent was offline append `set`, `replace`, or `remove` transitions without mutating or duplicating the original baseline. An incompatible visible baseline is superseded by one recomposed complete baseline in current precedence order, with explicit model-facing replacement language; an empty current candidate set emits an explicit clear baseline. A hot plugin remount follows the same compatibility rule. If no typed baseline remains visible, as after compaction shadows it, the loop composes and injects one complete current baseline. Compaction can shadow the baseline after this plugin's guarded `agent/step` listener has already run for the session. The `system-prompt/assemble` waterfall therefore delegates first, but restores only for an assembly explicitly marked for the loop's next model request; diagnostic assemblies remain read-only. When a prior typed baseline exists but none remains visible, the listener recomposes the current chain, rechecks cancellation and the current surface generation after every asynchronous probe, and injects before the loop drains its outbox and snapshots derived request history. A per-session settled marker prevents repeated preparation when the current generation produced no baseline; a separate queued marker plus the synchronous commit-time recheck lets concurrent preparations scan without queuing duplicate baselines. @@ -84,7 +84,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 baseline files are also noticed when a surface replacement or resume triggers recomposition. 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 baseline files are also noticed when a surface replacement recomposes the baseline or resume reconciles its current retained set. This keeps the design deterministic and provider-neutral. ## Deferred diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 1273bd9b46..51e43a4f2f 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -32,7 +32,7 @@ Status: implemented 该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 -恢复 agent 会基于持久化历史创建新的 loop 实例。如果带类型的基线仍位于可见表层,loop 会保留该事件,并在第一个请求前根据当前文件对账基线与动态 scope。未变文件不追加任何内容;agent 离线期间新增、编辑或移除的文件会追加 `set`、`replace` 或 `remove` 转换,既不改写也不重复追加原始基线。插件热重挂遵循相同的可见性规则。如果已无带类型的基线可见(例如压缩(compaction)将其遮蔽后),loop 会组合并注入一条完整的当前基线。 +恢复 agent 会基于持久化历史创建新的 loop 实例。如果带类型的基线仍位于可见表层,且其持久化的发现、优先顺序和预算标识与当前配置匹配,loop 会保留该事件,并在第一个请求前对账动态 scope 与当前完整渲染所保留的基线文件。未变文件和预算省略的文件都不追加任何内容;agent 离线期间新增、编辑、移除或从保留集中退出的文件会追加 `set`、`replace` 或 `remove` 转换,既不改写也不重复追加原始基线。不兼容的可见基线会被一条按当前优先顺序重新组合的完整基线取代,并以面向模型的明确措辞说明替换关系;当前候选集为空时,则会发出一条显式清除基线。插件热重挂遵循相同的兼容性规则。如果已无带类型的基线可见(例如压缩(compaction)将其遮蔽后),loop 会组合并注入一条完整的当前基线。 在本插件带防护的 `agent/step` 监听器已经为该会话运行后,压缩仍可能遮蔽基线。因此,`system-prompt/assemble` waterfall(瀑布式事件)会先委托,但只有当组装被明确标记为供 loop 的下一个模型请求使用时才恢复;诊断组装保持只读。如果此前存在带类型的基线、但已无基线可见,该监听器会重新组合当前文件链,在每次异步探测后重新检查取消状态和当前表层代次,并在 loop 排空 outbox 和对派生请求历史创建快照之前注入。逐会话的已结算标记会在当前代次没有产生基线时避免重复准备;单独的排队标记加上提交时同步复查,使并发准备可以扫描而不会排入重复基线。 @@ -84,7 +84,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, 仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 -系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;表层替换或恢复触发重新组合时,也会发现外部变更的基线文件。这使设计保持确定性并且与提供方无关。 +系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;表层替换重新组合基线,或恢复过程对账当前保留集时,也会发现外部变更的基线文件。这使设计保持确定性并且与提供方无关。 ## 延后事项 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f85d39dcd7..5da8769a91 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2256,7 +2256,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 diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl index 9abcd67f44..9bdaaf9e75 100644 --- a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"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":"\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"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"ba65bdb41810f4d0129129dcbd6cadcd643c069d"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"\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"}],"source":{"kind":"workspace-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":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl new file mode 100644 index 0000000000..79d87a18c6 --- /dev/null +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl @@ -0,0 +1,20 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"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":"\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"}],"source":{"kind":"workspace-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":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":0,"data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"\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"}],"source":{"kind":"workspace-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":"step/start","seq":9,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RESUME_DONE"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"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":[12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":18,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts index 1d1c03b71e..d962895839 100644 --- a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts +++ b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts @@ -19,12 +19,14 @@ import SessionStore, { } 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)) @@ -33,7 +35,16 @@ const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const oldInstruction = 'Old workspace instruction.' const newInstruction = 'New workspace instruction after offline edit.' -async function seedVisibleBaseline(root: string, cwd: string): Promise { +interface SeedBaselineOptions { + files?: Array<{ name: string; content: string }> + instructionFileCandidates?: string[] +} + +async function seedVisibleBaseline( + root: string, + cwd: string, + options: SeedBaselineOptions = {}, +): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) @@ -44,11 +55,19 @@ async function seedVisibleBaseline(root: string, cwd: string): Promise { cwd, delegationDepth: 0, } - const baseline = renderWorkspaceContext([{ - absolutePath: join(cwd, 'AGENTS.md'), - displayPath: 'AGENTS.md', - content: oldInstruction, - }], { maxBytes: 65536 }) + 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, trigger: { kind: 'message', source: { kind: 'user' } } } }, { @@ -67,12 +86,13 @@ async function seedVisibleBaseline(root: string, cwd: string): Promise { source: { kind: 'workspace-instructions', baseline: true, - changes: [{ + baselineIdentity: workspaceBaselineIdentity(config, cwd, cwd), + changes: files.map(file => ({ action: 'set', - scope: '.\0AGENTS.md', - path: 'AGENTS.md', - digest: createHash('sha1').update(oldInstruction).digest('hex'), - }], + scope: `.\0${file.name}`, + path: file.name, + digest: createHash('sha1').update(file.content).digest('hex'), + })), }, }), surfaceOp: 'append', @@ -148,4 +168,69 @@ describe('workspace-context resume snapshot', () => { reason: { kind: 'completed' }, }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('recomposes a compatible current-order 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).at(-1)) + .toMatchObject({ + type: 'result', + success: true, + sessionId, + result: 'RESUME_DONE', + reason: { kind: 'completed' }, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 478bf7e052..25292228e3 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.i18n.yaml @@ -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: 79f913a1cbabbcf474c5befccf01fb6eae76e843 -README.zh.md: 02f88c4e339b6a816b49db1eb9f91a440df787df +README.md: 16a1dd324b4ef8d87253d0cd0de1b61b850ae8be +README.zh.md: 63ed50162174469c1a2d3c254cbfff216bf73c14 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 79f913a1cb..16a1dd324b 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The ## Lifecycle -A complete baseline is injected at the first `agent/step` of a fresh session. It 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. The durable sourced `user/message` enters the same request as the claimed prompt. A resumed loop retains that baseline while it remains visible and appends only current-file transitions. If a later surface replacement such as compaction shadows the baseline, a model-request `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots that request; inspection-only assemblies do not mutate the session. +A complete baseline is injected at the first `agent/step` of a fresh session. It 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. The durable sourced `user/message` enters the same request as the claimed prompt. A resumed loop retains that baseline while it remains visible and its discovery, precedence, and budget identity matches the current configuration, then appends only transitions selected by the current baseline budget. An incompatible visible baseline is superseded by one recomposed complete baseline whose model-facing introduction states that replacement; if the current candidate set is empty, that baseline explicitly clears the earlier instructions. If a later surface replacement such as compaction shadows the baseline, a model-request `system-prompt/assemble` recomposes and injects the current chain before the loop snapshots that request; inspection-only assemblies do not mutate the session. 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 `` 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; a complete baseline also carries `baseline: true`. 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. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the 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, and budget configuration. 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. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the 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. A resumed loop or hot plugin remount retains that one visible baseline and reconciles its baseline and dynamic scopes against current files before the first request: unchanged files append nothing, while offline additions, edits, and removals append typed `set`, `replace`, and `remove` transitions. If no typed baseline remains visible, as after a surface replacement, model-request prompt assembly recomposes the complete current baseline and rechecks cancellation, visibility, and the current replacement generation immediately before injecting it. Concurrent preparations can read in parallel, but only the first commit queues a baseline; inspection-only assemblies never restore one. The in-memory scope marker and provider-version cache only select and accelerate probes. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a model request restores a shadowed baseline, 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. A resumed loop or hot plugin remount retains one compatible visible baseline and reconciles its dynamic scopes plus the baseline scopes retained by the current complete rendering before the first request: unchanged files append nothing, while offline additions, edits, and removals append typed `set`, `replace`, and `remove` transitions. A file omitted by the current baseline budget is not promoted into history by resume reconciliation; a previously visible file that leaves the retained set receives a removal transition. If the visible baseline identity is incompatible, one complete current baseline explicitly supersedes earlier baselines. If no typed baseline remains visible, as after a surface replacement, model-request prompt assembly recomposes the complete current baseline and rechecks cancellation, visibility, and the current replacement generation immediately before injecting it. Concurrent preparations can read in parallel, but only the first commit queues a baseline; inspection-only assemblies never restore one. The in-memory scope marker and provider-version cache only select and accelerate probes. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a model request restores a shadowed baseline, or when a resumed loop prepares its baseline. ## Configuration @@ -83,7 +83,7 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even #### What the model sees -A fresh session's first request contains one durable user-role message with the bounded user-global and project instruction chain in broad-to-specific order. A resumed request retains that message while it remains visible and adds only detected transitions; the first request after a surface replacement shadows it receives one recomposed complete baseline. +A fresh session's first request contains one durable user-role message with the bounded user-global and project instruction chain in broad-to-specific order. A resumed request retains that message while it remains visible and compatible, then adds only budget-selected transitions. An incompatible visible baseline is followed by a complete replacement baseline in current precedence order; the first request after a surface replacement shadows a baseline receives one recomposed complete baseline. ##### Baseline instruction template diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index 02f88c4e33..63ed501621 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -6,7 +6,7 @@ ## 生命周期 -完整基线会在全新会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。恢复的 loop 会在该基线仍可见时保留它,只追加根据当前文件检测到的转换。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;仅检查组装不会改变会话。 +完整基线会在全新会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求。恢复的 loop 会在该基线仍可见,且其发现、优先顺序和预算标识与当前配置匹配时保留它,然后只追加由当前基线预算选中的转换。不兼容的可见基线会被一条重新组合的完整基线取代,其面向模型的引言会明确说明替换关系;如果当前候选集为空,这条基线会显式清除先前的指令。如果后续表层替换(例如压缩(compaction))遮蔽了该基线,面向模型请求的 `system-prompt/assemble` 会在 loop 对该请求创建快照之前,重新组合并注入当前指令链;仅检查组装不会改变会话。 该插件还会监听 `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`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `step/end` 在匹配上下文进入日志之前到达,插件会清除 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。 +模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及根据规范化的发现、优先顺序和预算配置派生的 `baselineIdentity`。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。匹配的持久 `user/message` 会确认 pending 转换。如果所属 `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 只会导致一次确认读取。压缩会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone,因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache;已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。 -初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。恢复的 loop 或插件热重挂会保留这一条可见基线,并在第一个请求前根据当前文件对账其基线和动态 scope:未变文件不追加任何内容,而 agent 离线期间新增、编辑或移除的文件会追加带类型的 `set`、`replace` 或 `remove` 转换。如果已无带类型的基线可见(例如表层替换后),面向模型请求的提示词组装会重新组合完整的当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 +初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。恢复的 loop 或插件热重挂会保留一条兼容的可见基线,并在第一个请求前对账其动态 scope 以及当前完整渲染所保留的基线 scope:未变文件不追加任何内容,而 agent 离线期间新增、编辑或移除的文件会追加带类型的 `set`、`replace` 或 `remove` 转换。当前基线预算省略的文件不会因恢复对账而进入历史;先前可见但已不在保留集内的文件会收到移除转换。如果可见基线标识不兼容,一条完整的当前基线会明确取代此前的基线。如果已无带类型的基线可见(例如表层替换后),面向模型请求的提示词组装会重新组合完整的当前基线,并在注入前立即重新检查取消状态、可见性和当前替换代次。并发准备可以并行读取,但只有第一次提交会将一条基线排入队列;仅检查组装绝不会恢复基线。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。没有文件 watcher,因此磁盘变更会在下一次成功 `read`、`write` 或 `edit` touch 时可见,也会在模型请求恢复被遮蔽的基线时或恢复 loop 准备基线时可见。 ## 配置 @@ -83,7 +83,7 @@ export interface Config { #### 模型看到的内容 -全新会话的第一个请求包含一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。恢复后的请求会在该消息仍可见时保留它,并只追加检测到的转换;表层替换将其遮蔽后的第一个请求会收到一条重新组合的完整基线。 +全新会话的第一个请求包含一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。恢复后的请求会在该消息仍可见且兼容时保留它,然后只追加预算选中的转换。不兼容的可见基线之后会跟随一条按当前优先顺序排列的完整替换基线;表层替换将基线遮蔽后的第一个请求会收到一条重新组合的完整基线。 ##### 基线指令模板 diff --git a/packages/context/workspace-context/src/config.ts b/packages/context/workspace-context/src/config.ts index 56c048976c..c1a1fad1e6 100644 --- a/packages/context/workspace-context/src/config.ts +++ b/packages/context/workspace-context/src/config.ts @@ -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. diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index 3e6a3d5de8..bc426e43a4 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -46,12 +46,14 @@ 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. */ @@ -286,7 +288,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 +392,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,8 +415,22 @@ 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, + }), + 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)) } } diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index ad1cade619..117072b5ce 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -15,8 +15,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } 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, @@ -31,6 +31,7 @@ import { type InstructionVersionCache, type InstructionVersionUpdate, type PendingInstructionChange, + type WorkspaceInstructionSource, } from './state.ts' import type { WorkspaceInstructionChange } from './render.ts' @@ -46,13 +47,18 @@ export type { export { renderWorkspaceContext } from './render.ts' export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts' -function hasVisibleBaseline(session: Agent['session']): boolean { - return session.surface.nodes.some((seq) => { +function visibleBaselineSource(session: Agent['session']): WorkspaceInstructionSource | undefined { + for (const seq of session.surface.nodes.toReversed()) { const event = 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 hasVisibleBaseline(session: Agent['session']): boolean { + return visibleBaselineSource(session) !== undefined } function hasBaselineHistory(session: Agent['session']): boolean { @@ -88,7 +94,7 @@ export function apply(ctx: Context, config: Config): void { const prepareBaseline = async ( agent: Agent, signal: AbortSignal | undefined, - keepVisibleBaseline: boolean, + retainCompatibleBaseline: boolean, deduplicateRestore = false, ): Promise => { if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) { @@ -106,6 +112,21 @@ export function apply(ctx: Context, config: Config): void { } /* 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.session) + const keepVisibleBaseline = retainCompatibleBaseline + && visibleBaseline !== undefined + && typeof visibleBaseline.baselineIdentity === 'string' + && visibleBaseline.baselineIdentity === identity + const replacePreviousBaseline = retainCompatibleBaseline + && visibleBaseline !== undefined + && !keepVisibleBaseline const instructions = await loadBaselineInstructionSet({ cwd, dshHome: resolved.dshHome, @@ -114,6 +135,8 @@ export function apply(ctx: Context, config: Config): void { maxSourceBytes: resolved.maxSourceBytes, instructionFileCandidates: resolved.instructionFileCandidates, localInstructionFileCandidates: resolved.localInstructionFileCandidates, + projectRoot, + replacePreviousBaseline, ...signal === undefined ? {} : { signal }, }, fileSystem) const baseline = baselineInstructionState(instructions?.included ?? []) @@ -126,7 +149,12 @@ export function apply(ctx: Context, config: Config): void { pendingNestedChanges, instructionVersions, fileSystem, - { includeBaselineScopes: keepVisibleBaseline, ...signal === undefined ? {} : { signal } }, + { + includeBaselineScopes: keepVisibleBaseline, + ...keepVisibleBaseline ? { retainedBaselineScopes: new Set(baseline.changes.keys()) } : {}, + projectRoot, + ...signal === undefined ? {} : { signal }, + }, ) signal?.throwIfAborted() const generation = agent.session.surface.replaceGeneration @@ -141,6 +169,15 @@ export function apply(ctx: Context, config: Config): void { } if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) { const baselineMessage = workspaceContextMessage(instructions.rendered.text) + const replacementScopes = new Set(baseline.changes.keys()) + const visibleBaselineChanges = visibleBaseline?.changes ?? [] + const replacementRemovals = replacePreviousBaseline + ? visibleBaselineChanges.flatMap(change => ( + change.action === 'remove' || replacementScopes.has(change.scope) + ? [] + : [{ action: 'remove' as const, scope: change.scope, path: change.path }] + )) + : [] baselineSettledGeneration.delete(agent.session) baselineQueuedGeneration.set(agent.session, generation) try { @@ -149,7 +186,8 @@ export function apply(ctx: Context, config: Config): void { source: { kind: 'workspace-instructions', baseline: true, - changes: [...baseline.changes.values()], + baselineIdentity: identity, + changes: [...replacementRemovals, ...baseline.changes.values()], }, })) } catch (error: unknown) { @@ -165,8 +203,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { if (baselineLoaded.has(agent.session)) return - const keepVisibleBaseline = hasVisibleBaseline(agent.session) - await prepareBaseline(agent, signal, keepVisibleBaseline) + await prepareBaseline(agent, signal, true) }) ctx.on('system-prompt/assemble', async (_assembly, context, next) => { diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 9ab311e942..7b61f3b8fd 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -12,6 +12,10 @@ const SYSTEM_REMINDER_CLOSE = '' 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) } diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 383c765719..25fec1aa1d 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -41,6 +41,8 @@ export interface WorkspaceInstructionSource { kind: 'workspace-instructions' /** Marks a complete baseline rather than a later delta. */ baseline?: true + /** Discovery, precedence, and budget identity for safe baseline reuse. */ + baselineIdentity?: string changes: WorkspaceInstructionChange[] } @@ -386,7 +388,7 @@ function relativeScope(projectRoot: string, dir: string): string { * @param pendingBySession - short pending window before returned context is logged. * @param versionCache - per-session scope metadata used to skip unchanged reads. * @param fileSystem - provider used for current file probes. - * @param options - touched path and whether baseline scopes should participate. + * @param options - touched path and baseline-scope selection. * @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable. */ export async function reconcileInstructionContext( @@ -395,7 +397,13 @@ export async function reconcileInstructionContext( pendingBySession: WeakMap>, versionCache: InstructionVersionCache, fileSystem: FileSystem, - options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal }, + options: { + touchedPath?: string + includeBaselineScopes: boolean + retainedBaselineScopes?: ReadonlySet + projectRoot?: string + signal?: AbortSignal + }, ): Promise { const session = agent.session const pending = pendingChangesFor(session, pendingBySession) @@ -404,7 +412,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() const baselineScopes = new Set() const addDirScopes = (target: Set, directory: string): void => { @@ -455,6 +464,13 @@ export async function reconcileInstructionContext( for (const scope of scopes) { const { directory } = decodeScopeKey(scope) const previous = effective.get(scope) + if (options.retainedBaselineScopes !== undefined + && baselineScopes.has(scope) + && !options.retainedBaselineScopes.has(scope)) { + if (previous === undefined || previous.action === 'remove') versions.delete(scope) + else pushRemoval(scope, previous.path) + continue + } const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal) if (probe.kind === 'unavailable') { // Last-good-state: the candidate stays effective, so its cached trimmed diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index ed8f36c11c..13f9eebd34 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -1063,6 +1063,208 @@ describe('workspace context request injection', () => { } }) + 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.changes).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: string[] = [] + for (const event of baselines) { + if (event.type !== 'user/message' || event.data.source.kind !== 'workspace-instructions') continue + if (typeof event.data.source.baselineIdentity === 'string') { + baselineIdentities.push(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.changes).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.changes).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.changes).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('ignores a restored baseline during a file tool call before resume reconciliation', async () => { const root = await tempRepo() const home = await tempRepo() From f8ceeed610b82fddf3c0d0a10a996a2bef80b2ec Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 23:12:56 +0800 Subject: [PATCH 5/7] test(workspace-context): narrow instruction events --- .../tests/workspace-context.spec.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 13f9eebd34..b38e89c7d7 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -40,6 +40,7 @@ import { rollbackPendingInstructionChanges, type InstructionVersionCache, type PendingInstructionChange, + type WorkspaceInstructionSource, } from '../src/state.ts' import { candidateScopeKey, renderInstructionChanges } from '../src/render.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -227,11 +228,18 @@ function workspaceContextOf(result: { additionalContexts?: UserMessage[] }): Use context.source.kind === 'workspace-instructions') } -function baselineEvents(agent: Agent): SessionEvent[] { - return agent.session.events.filter(event => +type WorkspaceInstructionEvent = Extract & { + data: { source: WorkspaceInstructionSource } +} + +function workspaceInstructionEvents(agent: Agent): WorkspaceInstructionEvent[] { + return agent.session.events.filter((event): event is WorkspaceInstructionEvent => event.type === 'user/message' - && event.data.source.kind === 'workspace-instructions' - && event.data.source.baseline === true) + && event.data.source.kind === 'workspace-instructions') +} + +function baselineEvents(agent: Agent): WorkspaceInstructionEvent[] { + return workspaceInstructionEvents(agent).filter(event => event.data.source.baseline === true) } function workspaceChangeContext(scope: string, digest: string): UserMessage { @@ -1110,10 +1118,9 @@ describe('workspace context request injection', () => { 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.changes).toMatchObject([ + const update = workspaceInstructionEvents(resumed) + .findLast(event => event.data.source.baseline !== true) + expect(update?.data.source.changes).toMatchObject([ { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, { action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }, ]) @@ -1199,7 +1206,7 @@ describe('workspace context request injection', () => { 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.changes).toMatchObject([ + expect(claudeBaseline?.data.source.changes).toMatchObject([ { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, { action: 'set', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' }, ]) @@ -1212,7 +1219,7 @@ describe('workspace context request injection', () => { 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.changes).toMatchObject([ + expect(restoredBaseline?.data.source.changes).toMatchObject([ { action: 'remove', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' }, { action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) @@ -1248,9 +1255,9 @@ describe('workspace context request injection', () => { const baselines = baselineEvents(resumed) expect(baselines).toHaveLength(2) const replacement = baselines.at(-1) - expect(replacement?.type === 'user/message' && blocksText(replacement.data.content)) + expect(replacement === undefined ? '' : blocksText(replacement.data.content)) .toContain('No workspace instructions are currently active.') - expect(replacement?.type === 'user/message' && replacement.data.source.changes).toMatchObject([ + expect(replacement?.data.source.changes).toMatchObject([ { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) From c0433d71ee057170d3629212a73530e19c88a289 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 23:20:51 +0800 Subject: [PATCH 6/7] test(workspace-context): record baseline identity --- .../tests/snapshots/code-mode-workspace-context/session.jsonl | 2 +- .../acp-agent/tests/snapshots/workspace-context/session.jsonl | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 4588ebb898..93742afdfb 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1785014475015,"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":"a5066d26-ed57-4f98-8672-b34e883e1299"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"user/message","seq":3,"time":1785122256262,"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"},"role":"user","id":"c9aaa351-f7e6-40ef-955a-c5b8ee07667f"},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"\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"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]},"role":"user","id":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464674590,"data":{"content":[{"type":"text","text":"\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"}],"source":{"kind":"workspace-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":"68f653ef-7b05-4a60-a517-6dda5d3f4be4"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464674590,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464674590,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487644564,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 41faaef25f..71ceebedec 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"950c77c7-6a48-43aa-8e72-b6068d4e876b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope\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"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"95aaf126-946e-4ada-985a-943b490b6f2f"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785464650864,"data":{"content":[{"type":"text","text":"\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"}],"source":{"kind":"workspace-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":"95aaf126-946e-4ada-985a-943b490b6f2f"},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1785464650864,"data":{"turn":1,"step":1}} {"type":"request/header","seq":6,"time":1785464650864,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785487608778,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -18,7 +18,7 @@ {"type":"user/message","seq":16,"time":1785487608790,"data":{"content":[{"type":"text","text":"\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"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"24e6c34c-3fea-462b-8399-5d8b8c14eb9c"},"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785487608790,"data":{"turn":1,"step":1}} {"type":"user/message","seq":18,"time":1785762637747,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"5413be2d-cb6c-490c-9fa3-64b95c20b72b"},"sourceEventSeqs":[4],"surfaceOp":{"op":"replace","start":4,"end":4}} -{"type":"user/message","seq":19,"time":1785762637756,"data":{"content":[{"type":"text","text":"\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"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"5417d355-11a7-4d9a-b724-f63acf215392"},"surfaceOp":"append"} +{"type":"user/message","seq":19,"time":1785762637756,"data":{"content":[{"type":"text","text":"\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"}],"source":{"kind":"workspace-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":"5417d355-11a7-4d9a-b724-f63acf215392"},"surfaceOp":"append"} {"type":"step/start","seq":20,"time":1785762637756,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":22,"time":1785464650886,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}}} From 2b82225543c0103b436959fe5361f5fc61b36499 Mon Sep 17 00:00:00 2001 From: fz Date: Thu, 6 Aug 2026 20:21:15 +0800 Subject: [PATCH 7/7] test(workspace-context): align source form metadata --- .../offline-edit/session.expected.jsonl | 2 +- .../precedence-change/session.expected.jsonl | 2 +- .../headless-agent/tests/workspace-context-resume.snapshot.ts | 1 + .../context/workspace-context/tests/workspace-context.spec.ts | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl index afecfd7cab..8753b9241a 100644 --- a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl @@ -1,7 +1,7 @@ {"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":"\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"}],"source":{"kind":"workspace-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":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"\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"}],"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}}"}]}} diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl index a3fe2acc27..b9aabc1cd8 100644 --- a/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl +++ b/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl @@ -1,7 +1,7 @@ {"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":"\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"}],"source":{"kind":"workspace-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":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"\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"}],"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}}"}]}} diff --git a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts index 3f696b5fd6..789a859746 100644 --- a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts +++ b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts @@ -85,6 +85,7 @@ async function seedVisibleBaseline( content: [{ type: 'text', text: baseline.text }], source: { kind: 'workspace-instructions', + form: 'instructions', baseline: true, baselineIdentity: workspaceBaselineIdentity(config, cwd, cwd), changes: files.map(file => ({ diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 79fbe4937c..20bb993447 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -3072,6 +3072,7 @@ describe('dynamic nested workspace context injection', () => { 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' })