Merge remote-tracking branch 'origin/master' into worktree/tui-session-title-search

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-21-cross-session-references.md
#	.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md
#	packages/context/session-reference/README.i18n.yaml
#	packages/context/session-reference/README.md
#	packages/context/session-reference/README.zh.md
#	packages/context/session-reference/src/index.ts
This commit is contained in:
Yichen Jiang
2026-07-28 11:11:57 +08:00
1323 changed files with 41028 additions and 20842 deletions

View File

@@ -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
README.md: bc3237b98732c23e6a2b120e055f7713b91f9b7c
README.zh.md: b195a4c0b96b1f6f0b66bc6efa99a4a66b3c2fa2
README.md: a5244dfe99a714605744b57d33f97359d4d6fa4e
README.zh.md: 2c1bdd6e5b290a771094719daa6e4d7c3bf577db

View File

@@ -8,6 +8,6 @@ Product plugins that add model-visible request context without defining a tool.
|---|---|---|
| `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` |
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) |
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) |
The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.

View File

@@ -8,6 +8,6 @@
|---|---|---|
| `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` |
| `time-context/` | 持久的逐步骤当前时间与耗时上下文 | (无) |
| `workspace-context/` | `AGENTS.md``CLAUDE.md` 工作区上下文 loader | (监听 `agent/session-prefix` + `tools/post-execute` |
| `workspace-context/` | `AGENTS.md``CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute` |
[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了它的逐 agent会话隔离与生命周期拆分。

View File

@@ -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/session-reference/README.md
README.md: 97b8b44dd0822010f64397ebd4632edbeccb6863
README.zh.md: 921fa103e9fab0de5cdbb6ed7ef6ae863e41e3dc
README.md: 6def2923cf3bc0021b0db578279a1b0571106d41
README.zh.md: 9d7abfa78e6d35b2149c9436d5b397e4a30404d7

View File

@@ -2,19 +2,19 @@
English | [中文](README.zh.md)
`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as prompt-prefix context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI bundle mounts it, while other hosts may call the service directly.
`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as sourced model-facing context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI bundle mounts it, while other hosts may call the service directly.
## Public API
- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched.
- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`.
- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `UserMessageData` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`.
- `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:<base64url(JSON.stringify(sessionId))>` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text.
## Snapshot semantics
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `placement: 'prompt-prefix'`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and metadata for UI replay. Later source mutation, compaction, or deletion cannot change target replay.
The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The standard TUI preserves admission ownership without attaching context to the generic inbox record: outside the next-step acceptance window, a one-shot `agent/prompt-submit` wrapper adds the snapshot only to an allowed decision; during prompt admission or an open turn, `inject()` and `steer()` stage beside each other for the same safe boundary. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message` or `steering/message`. Later source mutation, compaction, or deletion cannot change target replay.
## Configuration
@@ -32,7 +32,7 @@ Retention applies `maxReferenceBytes` independently to each source, keeps compac
#### What the model sees
The model sees one user-role message in this order: the `## Referenced sessions` untrusted snapshot, the `## My request:` delimiter, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `<referenced-sessions>` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag.
The model sees two consecutive user-role messages: the `## Referenced sessions` untrusted snapshot, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `<referenced-sessions>` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag.
#### Token effect
@@ -40,7 +40,7 @@ Each referenced message adds the fixed warning plus up to three serialized snaps
#### KV Cache effect
The combined snapshot and request are append-only at the target message boundary and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary.
The snapshot and request are consecutive append-only target messages and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary.
## Known Limitations and Deferred Work

View File

@@ -2,19 +2,19 @@
[English](README.md) | 中文
`ctx.sessionReferences` 会把其他会话准备为有界、只读快照,作为提示词前缀上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。标准 TUI bundle 会装载它,其他宿主也可直接调用该服务。
`ctx.sessionReferences` 会把其他会话准备为有界、只读快照,作为带来源信息、面向模型的上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。标准 TUI bundle 会装载它,其他宿主也可直接调用该服务。
## 公开 API
- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或日志中最新的标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label标题不存在或无法读取时回退到会话 id。不搜索消息主体。
- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `HookContext`。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()``steer()` 之前被拒绝。
- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `UserMessageData` 上下文。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()``steer()` 之前被拒绝。
- `encodeSessionReferenceUri()``decodeSessionReferenceUri()` 实现 `dsh-session:<base64url(JSON.stringify(sessionId))>`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)``parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。显式 Markdown mention 会拒绝每个格式错误的 URI只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。
## 快照语义
准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的直接 user `user/message`、直接 user `steering/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compact` 源标记。对于已经包含烘焙前缀上下文的源提示词投影只读取其对模型隐藏的显示内容以防止快照递归传播。已遮蔽的压缩前事件、工具、reasoning、上下文、除已标记 compact 检查点外的插件生成 user 消息,以及未完成的 assistant chunk 均会被排除。因此,已压缩源贡献的是最新检查点与之后保留的会话,而非已恢复的遮蔽文本。
上下文源为 `{ kind: 'plugin', plugin: 'session-reference' }`,并携带 `placement: 'prompt-prefix'`。其元数据会记录版本 `1`、源 id 与 label、捕获 seq、是否存在 compact、已保留已省略消息数、已省略 UTF-8 字节数与截断状态。AgentLoop 将快照、`## My request:` 分隔符和有效提示词写入同一个 `user/message``steering/message`;同一事件的模型隐藏 envelope 保留直接提示词与元数据,用于 UI 回放。后续源变更、压缩或删除都无法改变目标回放。
上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留已省略消息数、已省略 UTF-8 字节数与截断状态。标准 TUI 在不把上下文附加到通用 inbox 记录的情况下保留接纳归属next-step 接收窗口之外,一次性 `agent/prompt-submit` 包装层只为获准决策添加快照;提示词接纳期间或轮次打开时,`inject()``steer()` 会并排暂存到同一安全边界。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message``steering/message`。后续源变更、压缩或删除都无法改变目标回放。
## 配置
@@ -32,7 +32,7 @@
#### 模型看到的内容
模型会按此顺序看到一条 user 角色消息:`## Referenced sessions` 不受信任快照`## My request:` 分隔符,随后是带可读 `@label` 的当前消息。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前 user 重复这些内容。Label、cwd 值、id 与会话文本作为 JSON 在 `<referenced-sessions>` 标签中序列化;每个数据 `<` 都发出为无损 JSON 转义 `\u003c`,因此源文本无法拼出框定标签。
模型会看到两条连续的 user 角色消息:先是 `## Referenced sessions` 不受信任快照,再是带可读 `@label` 的当前消息。警告禁止遵循快照中的指令、权限声明或工具请求,除非当前 user 重复这些内容。Label、cwd 值、id 与会话文本作为 JSON 在 `<referenced-sessions>` 标签中序列化;每个数据 `<` 都发出为无损 JSON 转义 `\u003c`,因此源文本无法拼出框定标签。
#### Token 影响
@@ -40,7 +40,7 @@
#### KV Cache 影响
组合快照与请求在目标消息边界处仅追加,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。
快照与请求是两条连续、仅追加的目标消息,并保留较早的可缓存历史。不同引用或源捕获内容只改变新后缀;后续目标压缩可能使从替换边界起的复用失效。
## 已知限制与暂缓事项

View File

@@ -7,9 +7,9 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId, UserMessageData } from '@deepseek-ai/dsh-session'
import type { SessionSurfaceSnapshot, SessionTitleObservationResult } from '@deepseek-ai/dsh-session-query'
import {
DEFAULT_CANDIDATE_LIMIT,
@@ -20,7 +20,7 @@ import {
} from './config.ts'
import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts'
import { stringifyTagSafeJson } from './serialization.ts'
import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput } from './types.ts'
import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceSource } from './types.ts'
export type * from './types.ts'
export type { Config, SessionReferenceErrorCode } from './config.ts'
@@ -163,7 +163,7 @@ export class SessionReferenceService extends Service {
* @param content - already host-normalized readable message content.
* @param references - structured source sessions in mention order.
* @param signal - optional cancellation boundary for host request teardown.
* @returns detached content and zero or one prepared contexts.
* @returns detached content and optional referenced-session context.
*/
async prepare(
agent: Agent,
@@ -173,7 +173,7 @@ export class SessionReferenceService extends Service {
): Promise<PreparedReferencedMessage> {
const acceptedContent = structuredClone(content)
const inputs = normalizeReferences(agent.id, references, this.config.maxReferences)
if (inputs.length === 0) return { content: acceptedContent, contexts: [] }
if (inputs.length === 0) return { content: acceptedContent }
assertNotCancelled(signal)
let prepared: PreparedSource[]
try {
@@ -196,7 +196,7 @@ export class SessionReferenceService extends Service {
const rendered = this.renderSources(prepared)
const prompt = renderPrompt(rendered.map(source => source.data))
const meta = {
const source: SessionReferenceSource = {
kind: 'session-reference',
version: 1,
references: rendered.map((source, index) => ({
@@ -206,14 +206,12 @@ export class SessionReferenceService extends Service {
...source.stats,
inputIndex: index,
})),
} satisfies JsonValue
const context: HookContext = {
source: { kind: 'plugin', plugin: 'session-reference' },
content: [{ type: 'text', text: prompt }],
placement: 'prompt-prefix',
meta,
}
return { content: acceptedContent, contexts: [context] }
const additionalContext: UserMessageData = {
source,
content: [{ type: 'text', text: prompt }],
}
return { content: acceptedContent, additionalContext }
}
private renderSources(sources: readonly PreparedSource[]): RenderedSource[] {

View File

@@ -1,7 +1,6 @@
/** Current-surface projection and byte-bounded rendering. */
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
import { displayPromptContent } from '@deepseek-ai/dsh-session'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { TextRetainer } from '@deepseek-ai/dsh-retention'
@@ -41,13 +40,13 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected
case 'user/message': {
const checkpoint = isCompactCheckpointSource(event.data.source)
if (!checkpoint && event.data.source.kind !== 'user') break
const text = textContent(displayPromptContent(event.data))
const text = textContent(event.data.content)
if (text !== '') conversation.push({ role: 'user', text, checkpoint, originalText: text, omittedBytes: 0 })
break
}
case 'steering/message': {
if (event.data.source.kind !== 'user') break
const text = textContent(displayPromptContent(event.data))
const text = textContent(event.data.content)
if (text !== '') conversation.push({ role: 'user', text, checkpoint: false, originalText: text, omittedBytes: 0 })
break
}

View File

@@ -1,8 +1,31 @@
/** Public session-reference request, candidate, and preparation records. */
import type { HookContext } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId, UserMessageData } from '@deepseek-ai/dsh-session'
/** Durable provenance for one prepared cross-session context. */
export interface SessionReferenceSource {
kind: 'session-reference'
version: 1
references: {
sessionId: string
label: string
capturedThroughSeq: number | null
compacted: boolean
originalMessages: number
retainedMessages: number
omittedMessages: number
omittedBytes: number
truncated: boolean
inputIndex: number
}[]
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'session-reference': SessionReferenceSource
}
}
/** One source session selected by a host. */
export interface SessionReferenceInput {
@@ -24,12 +47,12 @@ export interface SessionReferenceCandidate {
createdAt: number
}
/** Message payload and the zero-or-one durable snapshot contexts bound to it. */
/** Direct message content and optional referenced-session context. */
export interface PreparedReferencedMessage {
/** Readable message content after host mention tokens are removed. */
content: ContentBlock[]
/** Empty without references; otherwise one aggregated untrusted context. */
contexts: HookContext[]
/** Aggregated untrusted snapshot, absent when the message has no references. */
additionalContext?: UserMessageData
}
/** Text-only projected conversation item. */

View File

@@ -278,11 +278,9 @@ describe('session reference discovery and preparation', () => {
[{ sessionId: source.id, label: 'source' }],
)
expect(prepared.content).toEqual([{ type: 'text', text: 'use @source' }])
expect(prepared.contexts).toHaveLength(1)
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(context.source).toEqual({ kind: 'plugin', plugin: 'session-reference' })
expect(context.placement).toBe('prompt-prefix')
expect(context.source).toMatchObject({ kind: 'session-reference' })
expect(context.content[0].text).toContain('untrusted, read-only snapshot')
expect(promptData(context.content[0].text)).toEqual([{
sessionId: 'source',
@@ -296,7 +294,7 @@ describe('session reference discovery and preparation', () => {
{ role: 'assistant', text: 'visible answer' },
],
}])
expect(context.meta).toMatchObject({
expect(context.source).toMatchObject({
kind: 'session-reference',
version: 1,
references: [{
@@ -316,21 +314,17 @@ describe('session reference discovery and preparation', () => {
expect(context.content[0].text).not.toContain('later source mutation')
})
it('projects only the direct prompt when a source message contains baked prefix context', async () => {
it('excludes injected context when projecting a referenced session', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
source.append('user/message', {
content: [
{ type: 'text', text: 'nested referenced snapshot must not propagate' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'direct source question' },
],
content: [{ type: 'text', text: 'nested referenced snapshot must not propagate' }],
source: { kind: 'plugin', plugin: 'session-reference' },
}, { surfaceOp: 'append' })
source.append('user/message', {
content: [{ type: 'text', text: 'direct source question' }],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'direct source question' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'session-reference' } }],
},
}, { surfaceOp: 'append' })
const prepared = await ctx.sessionReferences.prepare(
@@ -338,7 +332,7 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'inspect source' }],
[{ sessionId: source.id }],
)
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(promptData(context.content[0].text)).toMatchObject([{
conversation: [{ role: 'user', text: 'direct source question' }],
@@ -362,7 +356,7 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }],
)
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const prompt = context.content[0].text
expect(prompt).toMatch(/^## Referenced sessions\n/u)
@@ -387,14 +381,14 @@ describe('session reference discovery and preparation', () => {
const content = [{ type: 'text' as const, text: 'go' }]
const withoutReferences = await ctx.sessionReferences.prepare(agent, content, [])
expect(withoutReferences).toEqual({ content, contexts: [] })
expect(withoutReferences).toEqual({ content })
expect(withoutReferences.content).not.toBe(content)
await expect(ctx.sessionReferences.prepare(agent, content, [
{ sessionId: one.id, label: 'first' },
{ sessionId: one.id, label: 'ignored duplicate' },
{ sessionId: two.id },
])).resolves.toMatchObject({ contexts: [{ meta: { references: [{ label: 'first' }, { label: 'two' }] } }] })
])).resolves.toMatchObject({ additionalContext: { source: { references: [{ label: 'first' }, { label: 'two' }] } } })
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: target.id }]))
.rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE'))
await expect(ctx.sessionReferences.prepare(agent, content, [null as never]))
@@ -465,14 +459,14 @@ describe('session reference discovery and preparation', () => {
)
const prepared = await ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }])
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const data = promptData(context.content[0].text) as unknown[]
expect(Buffer.byteLength(stringifyTagSafeJson(data[0]), 'utf8')).toBeLessThanOrEqual(360)
expect(context.content[0].text).toContain('checkpoint')
expect(context.content[0].text).toContain('latest-')
expect(context.content[0].text).toContain('omitted')
expect(context.meta).toMatchObject({ references: [{ truncated: true, compacted: true }] })
expect(context.source).toMatchObject({ references: [{ truncated: true, compacted: true }] })
})
it('applies the full byte limit independently to each of three references', async () => {
@@ -499,7 +493,7 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'go' }],
sources.map(source => ({ sessionId: source.id })),
)
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const data = promptData(context.content[0].text) as unknown[]
const sizes = data.map(source => Buffer.byteLength(stringifyTagSafeJson(source), 'utf8'))
@@ -532,18 +526,12 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }],
)
const context = prepared.contexts[0]
const context = prepared.additionalContext
if (context === undefined) throw new Error('expected prepared context')
target.append('user/message', context, { surfaceOp: 'append' })
target.append('user/message', {
content: [...context.content, { type: 'text', text: '\n\n## My request:\n' }, ...prepared.content],
content: prepared.content,
source: { kind: 'user' },
envelope: {
displayContent: prepared.content,
prefixContexts: [{
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
}],
},
}, { surfaceOp: 'append' })
const before = target.deriveMessages()
@@ -570,7 +558,7 @@ describe('session reference discovery and preparation', () => {
expect(ctx.sessions.get(source.id)).toBeUndefined()
expect(target.deriveMessages()).toEqual(before)
expect(JSON.stringify(before)).toContain('durable referenced fact')
expect(JSON.stringify(before)).toContain('## My request:')
expect(JSON.stringify(before)).toContain('use @source')
expect(JSON.stringify(before)).not.toContain('later source mutation')
expect(new Session(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
})

View File

@@ -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
README.md: db6d4e2cc9b68f94fe7cacd85c302c4242c88930
README.zh.md: 06e13824109f76242aaae2d302e984a8598cbc98
README.md: 9fe818855439466b2a3e349cd54a2f408cf5ec10
README.zh.md: 337ce3613d7b17134db7cf85a808881017f4e2c3

View File

@@ -20,7 +20,7 @@ When `timeZone` is omitted, the plugin resolves the Node process's system zone o
## Timing semantics
The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
The plugin prepends an `agent/step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.

View File

@@ -20,7 +20,7 @@
## 时序语义
该插件会前置一个 `agent/pre-step` listener。需要注入时它会追加一条注入的 `user/message`,通过 `agent.inject()` 完成,时机位于 `step/start` 和普通自动压缩之前,其源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制的尝试不追加任何内容。
该插件会前置一个 `agent/step` listener。需要注入时它会追加一条注入的 `user/message`,通过 `agent.inject()` 完成,时机位于 `step/start` 和普通自动压缩之前,其源为 `{ kind: 'plugin', plugin: 'time-context' }`。被抑制的尝试不追加任何内容。
正间隔调度会扫描原始持久会话事件,查找最新的上述源 `user/message`,包括已被压缩遮蔽的 reading。因此调度可以跨轮次和已恢复进程应用不需要进程本地 cache 状态。它会降低追加频率与历史增长,但绝不移除现有 reading且每个会话独立调度。

View File

@@ -156,7 +156,7 @@ export function apply(ctx: Context, config: Config): void {
}
const resolvedTimeZone = formatter.resolvedOptions().timeZone
ctx.on('agent/pre-step', (
ctx.on('agent/step', (
agent: Agent,
turn: number,
step: number,
@@ -173,9 +173,6 @@ export function apply(ctx: Context, config: Config): void {
const previous = step === 1
? precedingMessageTime(agent)
: precedingStepContextTime(agent, turn)
agent.inject(
[{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }],
{ source: { kind: 'plugin', plugin: name } },
)
agent.inject({ content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], source: { kind: 'plugin', plugin: name } })
}, { prepend: true })
}

View File

@@ -41,15 +41,12 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
options: {},
session,
status: 'running',
acceptsNextStep: true,
ctx: new Context(),
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
}, { surfaceOp: 'append' })
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
send: () => AgentMessageId('stub'),
@@ -85,7 +82,7 @@ async function fire(
step: number,
signal: AbortSignal = SIGNAL,
): Promise<void> {
await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, signal)
await agentEvents(ctx, agent).serial('agent/step', turn, step, signal)
}
function textResponse(text: string): StreamChunk[] {
@@ -294,7 +291,7 @@ describe('durable step context', () => {
const agent = sessionAgent(session)
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/pre-step', (subject) => {
ctx.on('agent/step', (subject) => {
ordinarySawContext = subject.session.events.some(event => event.type === 'user/message')
})
@@ -363,22 +360,22 @@ describe('real agent-loop request history', () => {
it.each([
['throws', 'error'],
['cancels', 'aborted'],
] as const)('retains the preparation reading when a later pre-step listener %s', async (mode, reasonKind) => {
] as const)('discards the pending preparation reading when a later step listener %s', async (mode, reasonKind) => {
const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter)
let laterSawReading = false
ctx.on('agent/pre-step', (subject) => {
ctx.on('agent/step', (subject) => {
laterSawReading = contextTexts(subject.session).length === 1
if (mode === 'throws') throw new Error('later pre-step failure')
subject.cancel({ kind: 'user' })
})
const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'start' }])
agent.followup({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(laterSawReading).toBe(true)
expect(contextTexts(agent.session)).toHaveLength(1)
expect(laterSawReading).toBe(false)
expect(contextTexts(agent.session)).toHaveLength(0)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
@@ -400,7 +397,7 @@ describe('real agent-loop request history', () => {
}))
const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'start' }])
agent.followup({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 0edea95866bf606216b6c24d2667152a479f757a
README.zh.md: 0d5503dba2a816acf5fe7075278f98d31d769f48
# pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md
README.md: df75b29dd3e8dbb504aac9e9885c32a809cbf70f
README.zh.md: 8bd926302f09ecdf453c7832b3a15b0e7fcc1b2a

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
Per-session workspace instruction loading for `AGENTS.md`-compatible files. The plugin freezes the initial user-global and project instruction chain into the request prefix, then discovers nested files and reports later changes or removals through durable context messages after successful filesystem tool calls.
Per-session workspace instruction loading for `AGENTS.md`-compatible files. The plugin injects the initial user-global and project instruction chain into durable history, then discovers nested files and reports later changes or removals after successful filesystem tool calls.
## Lifecycle
The baseline is composed once per agent-loop instance on `agent/session-prefix`. 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 prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
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.
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.
@@ -14,7 +14,7 @@ Instruction reads use the optional `ctx.fs` provider. The plugin does not static
## Prompt Shape
Baseline instructions are request-only user-role prefix messages framed with the familiar system-reminder pattern:
Baseline instructions are durable user-role messages framed with the familiar system-reminder pattern:
```md
<system-reminder>
@@ -30,7 +30,7 @@ Instructions from: AGENTS.md
</system-reminder>
```
Newly reached scopes use a durable injected `user/message` (plugin source):
Newly reached scopes use a durable sourced `user/message`:
```md
<system-reminder>
@@ -44,15 +44,15 @@ These instructions apply to work under `packages/app`. Use them as guidance when
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
The plugin owns the complete `<system-reminder>` framing, and every injected `user/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
The plugin owns the complete `<system-reminder>` framing, and every injected `user/message` reaches the model verbatim with no core wrapper.
## State And Refresh
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. 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; 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.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata 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 session log, 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 metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
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 frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix.
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it 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, or when a resumed loop prepares its baseline.
## Configuration
@@ -75,15 +75,15 @@ The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both
Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in the structured message source.
## Model Experience
### Baseline session prefix
### Baseline context
#### What the model sees
At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order.
At the first request of each loop instance, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order.
##### Baseline instruction template
@@ -103,17 +103,17 @@ Instructions from: AGENTS.md
#### Token effect
The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
The rendered baseline is appended once and remains in derived history until compaction. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens.
#### KV Cache effect
Prefix-stable within one loop instance because the baseline is frozen. A new or resumed instance recomposes it, so instruction, precedence, cwd, candidate, or byte-budget changes may invalidate reuse from the first changed baseline token.
Append-only after the existing reusable prefix. A new or resumed instance may append a recomposed baseline, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position.
### Newly discovered scope context
#### What the model sees
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained injected `user/message` with the newly applicable instruction file.
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained sourced `user/message` with the newly applicable instruction file.
##### Additional instruction template
@@ -162,7 +162,7 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix.
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop prepares its baseline.
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration.
- **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it.
- **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories.

View File

@@ -2,19 +2,19 @@
[English](README.md) | 中文
为每个会话加载与 `AGENTS.md` 兼容的工作区指令文件。该插件会将初始 user 全局指令与项目指令链冻结到请求前缀中,随后发现嵌套文件,并在成功的文件系统工具调用后通过持久上下文消息报告后续变更或移除。
为每个会话加载与 `AGENTS.md` 兼容的工作区指令文件。该插件会将初始 user 全局指令与项目指令链注入持久历史,随后发现嵌套文件,并在成功的文件系统工具调用后报告后续变更或移除。
## 生命周期
基线会在每个 agent-loop 实例的 `agent/session-prefix` 上组合一次。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。前缀放在所有派生历史之前,记录在 `EpochHeader.messagePrefix` 中,并为该 loop 实例逐字复用。因为插件在委托之前前置自身贡献,后注册的 skill catalog 会出现在工作区指令之后
基线会在每个实时会话的第一个 `agent/step` 注入。它先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。这条持久的带来源 `user/message` 与被认领的提示词进入同一个请求
该插件还会监听 `tools/post-execute` 中成功的第一方 `read``write``edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell解析任意 shell 语法也不可靠。
指令读取使用可选 `ctx.fs` 提供方。该插件不会静态注入 `fs`,因此没有提供方的产品树仍可启动,指令加载在提供方出现前不执行任何操作。它会解析每个候选文件并获取结果状态,因此会跟随最终组件 symlink 到其目标指向常规文件的链接会加载目标内容缺失路径或非文件目标包括指向目录的链接则已确认不存在。resolve 或 stat 异常会改为将该候选文件的 scope 标记为暂时不可用。前缀取消与动态工具取消会传播到解析、元数据探测与流式读取。文件加载后的提供方失败会视为暂时不可用,而非文件已删除的证据。
指令读取使用可选 `ctx.fs` 提供方。该插件不会静态注入 `fs`,因此没有提供方的产品树仍可启动,指令加载在提供方出现前不执行任何操作。它会解析每个候选文件并获取结果状态,因此会跟随最终组件 symlink 到其目标指向常规文件的链接会加载目标内容缺失路径或非文件目标包括指向目录的链接则已确认不存在。resolve 或 stat 异常会改为将该候选文件的 scope 标记为暂时不可用。步骤取消与动态工具取消会传播到解析、元数据探测与流式读取。文件加载后的提供方失败会视为暂时不可用,而非文件已删除的证据。
## 提示词形状
基线指令是仅请求的 user 角色前缀消息,使用熟悉的 system-reminder 模式框定:
基线指令是持久的 user 角色消息,使用熟悉的 system-reminder 模式框定:
```md
<system-reminder>
@@ -30,7 +30,7 @@ Instructions from: AGENTS.md
</system-reminder>
```
新达到的 scope 使用持久注入 `user/message`(插件源)
新达到的 scope 使用持久的带来源 `user/message`
```md
<system-reminder>
@@ -44,15 +44,15 @@ These instructions apply to work under `packages/app`. Use them as guidance when
同一文件的编辑以 `Updated instructions from: <path>` 开头,并说明使用新内容替代之前加载的内容。候选文件消失或成为同一目录中较早候选文件的重复项时,消息是 `Instructions removed: <path>`,后跟 `The previously loaded instructions from this file no longer apply.`。指令文件中的字面 `</system-reminder>` 文本会转义,因此文件内容无法关闭插件拥有的 frame。
该插件拥有完整 `<system-reminder>` framing每个注入的 `user/message`(无论来自此插件还是其他插件)都会不加包装地逐字达到模型,成为 user 角色消息
该插件拥有完整 `<system-reminder>` framing每个注入的 `user/message` 都会在没有核心包装的情况下逐字达到模型
## 状态与刷新
模型可见文本不含隐藏状态标记。每个动态上下文事件改为携带 JSON 元数据,其中包含经版本化的 `{ action, scope, path, digest? }` 变更列表。每次相关工具 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 的版本刷新只更新元数据
路径与 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
冻结基线自身不会在实例中途改写。其初始路径digest map 保留为比较状态;下一次成功文件系统 touch 会追加任何基线替换或移除。恢复的 loop 重新组合当前基线,并在前缀组合期间对账仍可见的动态 scope。没有文件 watcher因此磁盘变更会在下一次成功 `read``write``edit` touch 时可见,也会在恢复 loop 组合前缀时可见。
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功文件系统 touch 会在压缩后重新添加未变的基线 scope或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会注入当前基线。恢复的 loop 始终重新组合当前基线,并在第一个请求前对账仍可见的动态 scope。没有文件 watcher因此磁盘变更会在下一次成功 `read``write``edit` touch 时可见,也会在恢复 loop 准备基线时可见。
## 配置
@@ -75,15 +75,15 @@ user 全局文件始终是 `$DSH_HOME/AGENTS.md`,没有本地 overlay两个
渲染会优先保留最具体的指令文件。它会先丢弃完整的较宽泛文件,再截断最具体文件,并发出可见 `Workspace instruction budget ...` 通知,其中指名已省略与已截断路径。渲染后字节数绝不超过 `maxBytes`
即使提供方元数据省略大小,或文件在元数据探测后增长,指令内容仍会通过 `streamText()``maxSourceBytes` 下读取。超大文件会被忽略;在动态对账期间,它会暂时不可用,而不是被移除。该插件不保留进程级 cache绝不缓存指令文本。其会话本地 scope cache 只将提供方版本用作快速失效信号;失效后,对有界读取计算的 SHA-1 仍是存储在结构化会话元数据中的跨提供方内容身份。
即使提供方元数据省略大小,或文件在元数据探测后增长,指令内容仍会通过 `streamText()``maxSourceBytes` 下读取。超大文件会被忽略;在动态对账期间,它会暂时不可用,而不是被移除。该插件不保留进程级 cache绝不缓存指令文本。其会话本地 scope cache 只将提供方版本用作快速失效信号;失效后,对有界读取计算的 SHA-1 仍是存储在结构化消息来源中的跨提供方内容身份。
## 模型体验
### 基线会话前缀
### 基线上下文
#### 模型看到的内容
在每个 loop 实例的第一个请求中,模型会收到一条 user 角色前缀消息,其中按从宽泛到具体的顺序包含有界 user 全局指令与项目指令链。
在每个 loop 实例的第一个请求中,模型会收到一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界 user 全局指令与项目指令链。
##### 基线指令模板
@@ -103,17 +103,17 @@ Instructions from: AGENTS.md
#### Token 影响
渲染后基线会被冻结,并在该 loop 实例的每个请求中重发`maxBytes` 会限制完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。
渲染后基线只追加一次,并保留在派生历史中直到压缩`maxBytes` 会限制完整消息,较宽泛文件在最具体文件截断之前被省略,空指令链不产生 token。
#### KV Cache 影响
由于基线已冻结,前缀在同一 loop 实例内保持稳定。新建或恢复的实例重新组合因此指令、优先级、cwd、候选文件或字节预算变更可能使从第一个改变的基线 token 起的复用失效
仅追加,位于现有可复用前缀之后。新建或恢复的实例可能追加重新组合的基线因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用
### 新发现的 scope 上下文
#### 模型看到的内容
成功的第一方文件系统调用达到更深目录后,下一个请求会包含一条保留的注入 `user/message`,其中包含新适用的指令文件。
成功的第一方文件系统调用达到更深目录后,下一个请求会包含一条保留的带来源 `user/message`,其中包含新适用的指令文件。
##### 附加指令模板
@@ -162,7 +162,7 @@ The previously loaded instructions from this file no longer apply.
## 已知限制与暂缓事项
- **发现跟随结构化 fs 工具,而非 shell 导航**:更改目录的 `bash` 命令不会触发嵌套指令发现,因为 shell 语法与每次调用 shell 状态不是可靠的文件系统 seam。
- **刷新由 touch 驱动**:没有 watcher外部编辑会在下一次成功的第一方 `read``write``edit` 时可见,也会在恢复 loop 重新组合前缀时可见。
- **刷新由 touch 驱动**:没有 watcher外部编辑会在下一次成功的第一方 `read``write``edit` 时可见,也会在恢复 loop 准备基线时可见。
- **候选语义有意保持简单**:不解释小写名称、`.claude/rules/``@path` import项目 scope 默认加载 `AGENTS.local.md``CLAUDE.local.md` overlay但 user 全局 `$DSH_HOME` scope 没有本地 overlay其他自定义名称需要显式候选配置。
- **每目录去重基于内容**:只有在去除首尾空白后字节完全一致时,才折叠同级候选文件。`CLAUDE.md` 若 symlink 到同级 `AGENTS.md`,会解析为相同内容,并像任何重复项一样折叠;从 `AGENTS.md` 漂移的独立实体副本则会与它一起完整加载。
- **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低权限的工作区指引(它绝不会覆盖 system、developer 或直接 user 指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`

View File

@@ -1,7 +1,7 @@
/**
* Workspace instruction loader for AGENTS.md-compatible files.
*
* Baseline instructions are frozen into `agent/session-prefix`; successful fs
* Baseline instructions enter durable context before the first request; successful fs
* tool touches reconcile nested, changed, and removed instructions through
* `tools/post-execute` for the next model request. Plugin lifecycle reads use
* the optional `ctx.fs` provider, so providerless products mount it as a no-op.
@@ -11,7 +11,6 @@
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
import { loadBaselineInstructionSet } from './files.ts'
@@ -44,27 +43,53 @@ export type {
export { renderWorkspaceContext } from './render.ts'
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
function hasVisibleBaseline(agent: Agent): boolean {
return agent.session.surface.nodes.some((seq) => {
const event = agent.session.events[seq]
return event?.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.baseline === true
})
}
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = resolveConfig(config)
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
const baselineInstructionStates = new WeakMap<object, Map<string, WorkspaceInstructionChange>>()
const baselineSessions = new WeakSet<object>()
const instructionVersions: InstructionVersionCache = new WeakMap()
const pendingVersionUpdates = new Map<ToolExecutionToken, InstructionVersionUpdate[]>()
const baselineLoaded = new WeakSet<object>()
// 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<object>()
const pendingByParent = new Map<ToolExecutionToken, {
agent: Agent
changes: WorkspaceInstructionChange[]
versionUpdates: InstructionVersionUpdate[]
}>()
ctx.on('agent/session-start', (agent: Agent) => {
lifecycleWitnessed.add(agent.session)
})
ctx.on('session/event', (session, event) => {
observeInstructionSessionEvent(session, event, pendingNestedChanges, instructionVersions)
})
ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise<Message[]> => {
const rest = await next()
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest
ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise<void> => {
if (baselineLoaded.has(agent.session)) return
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) {
baselineLoaded.add(agent.session)
return
}
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) return rest
if (fileSystem === undefined) {
baselineLoaded.add(agent.session)
return
}
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
const instructions = await loadBaselineInstructionSet({
@@ -78,27 +103,34 @@ export function apply(ctx: Context, config: Config): void {
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
baselineInstructionStates.set(agent.session, baseline.changes)
baselineSessions.add(agent.session)
instructionVersions.set(agent.session, baseline.versions)
const update = await reconcileInstructionContext(
agent,
resolved,
pendingNestedChanges,
baselineInstructionStates,
instructionVersions,
fileSystem,
{ includeBaselineScopes: false, signal },
)
if (update !== undefined) {
agent.inject(update.context.content, {
source: update.context.source,
meta: update.context.meta,
})
agent.inject({ content: update.context.content, source: update.context.source })
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
}
if (instructions === undefined || instructions.rendered.text.length === 0) return rest
return [workspaceContextMessage(instructions.rendered.text), ...rest]
const keepVisibleBaseline = !lifecycleWitnessed.has(agent.session) && hasVisibleBaseline(agent)
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
const baselineMessage = workspaceContextMessage(instructions.rendered.text)
agent.inject({
content: baselineMessage.content,
source: {
kind: 'workspace-instructions',
baseline: true,
changes: [...baseline.changes.values()],
},
})
}
baselineLoaded.add(agent.session)
})
ctx.on('tools/post-execute', async (
@@ -122,7 +154,7 @@ export function apply(ctx: Context, config: Config): void {
result,
resolved,
pendingNestedChanges,
baselineInstructionStates,
baselineSessions,
instructionVersions,
fileSystem,
)

View File

@@ -15,7 +15,7 @@ export const name = 'workspace-context-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: replay intentionally tolerates unknown or malformed workspace metadata,
* No runtime invariant: replay intentionally tolerates unknown or malformed workspace sources,
* while focused pipeline tests own its private pending/cache state transitions.
*/
const install: InvariantInstaller = () => {}

View File

@@ -4,9 +4,9 @@
* @module @deepseek-ai/dsh-workspace-context/state
*/
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, UserMessageData } from '@deepseek-ai/dsh-session'
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts'
@@ -33,9 +33,22 @@ import {
export const name = 'workspace-context'
const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const
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. */
baseline?: true
changes: WorkspaceInstructionChange[]
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'workspace-instructions': WorkspaceInstructionSource
}
}
/** Dynamic state waiting for the loop to append its returned context event. */
export interface PendingInstructionChange {
change: WorkspaceInstructionChange
@@ -66,28 +79,19 @@ export interface InstructionVersionUpdate {
/** Rendered reconciliation plus cache transitions awaiting final policy. */
export interface ReconciledInstructionContext {
context: WorkspaceHookContext
context: UserMessageData
versionUpdates: InstructionVersionUpdate[]
}
/** Plugin-owned context with required replay metadata. */
export interface WorkspaceHookContext extends HookContext {
meta: JsonValue
}
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext {
const serializedChanges: JsonValue[] = changes.map(change => ({
action: change.action,
scope: change.scope,
path: change.path,
...change.digest !== undefined ? { digest: change.digest } : {},
}))
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta }
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessageData {
return {
content: [{ type: 'text', text }],
source: { kind: 'workspace-instructions', changes },
}
}
/**
* Build the request-prefix message for a rendered baseline.
* Build the user-role message for a rendered baseline.
* @param text - complete plugin-owned system-reminder text.
* @returns a user-role prefix message.
*/
@@ -103,20 +107,21 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
return filePath.length > 0 ? filePath : undefined
}
function isWorkspaceContextSource(source: unknown): source is typeof PLUGIN_SOURCE {
function isWorkspaceContextSource(
source: unknown,
): source is { kind: 'workspace-instructions'; changes: unknown[] } {
return typeof source === 'object' && source !== null
&& 'kind' in source && source.kind === 'plugin'
&& 'plugin' in source && source.plugin === name
&& 'kind' in source && source.kind === 'workspace-instructions'
&& 'changes' in source && Array.isArray(source.changes)
}
function isRecord(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInstructionChange[] {
if (!isRecord(meta) || meta.kind !== 'workspace-instructions' || meta.version !== 1 || !Array.isArray(meta.changes)) return []
function workspaceInstructionChanges(source: { changes: unknown[] }): WorkspaceInstructionChange[] {
const changes: WorkspaceInstructionChange[] = []
for (const value of meta.changes) {
for (const value of source.changes) {
if (!isRecord(value)) continue
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
@@ -146,7 +151,7 @@ function visibleInstructionChanges(
const visible = new Map<string, WorkspaceInstructionChange>()
for (const [seq, event] of agent.session.events.entries()) {
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
const changes = workspaceInstructionChanges(event.data.meta)
const changes = workspaceInstructionChanges(event.data.source)
for (const change of changes) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
@@ -283,7 +288,7 @@ export function observeInstructionSessionEvent(
switch (event.type) {
case 'user/message': {
if (!isWorkspaceContextSource(event.data.source)) return
for (const change of workspaceInstructionChanges(event.data.meta)) {
for (const change of workspaceInstructionChanges(event.data.source)) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
pending.delete(change.scope)
@@ -322,14 +327,14 @@ export function observeInstructionSessionEvent(
*/
export function commitPendingInstructionContexts(
agent: Agent,
contexts: readonly HookContext[] | undefined,
contexts: readonly UserMessageData[] | undefined,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): WorkspaceInstructionChange[] {
const committed: WorkspaceInstructionChange[] = []
const step = openStep(agent.session)
for (const context of contexts ?? []) {
if (!isWorkspaceContextSource(context.source)) continue
const changes = workspaceInstructionChanges(context.meta)
const changes = workspaceInstructionChanges(context.source)
if (changes.length === 0) continue
const pending = pendingChangesFor(agent.session, pendingBySession)
for (const change of changes) {
@@ -375,50 +380,49 @@ function relativeScope(projectRoot: string, dir: string): string {
* @param agent - session owner whose visible surface supplies durable state.
* @param resolved - normalized plugin configuration.
* @param pendingBySession - short pending window before returned context is logged.
* @param baselineBySession - frozen baseline comparison state per session.
* @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 be checked.
* @param options - touched path and whether baseline scopes should participate.
* @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
*/
export async function reconcileInstructionContext(
agent: Agent,
resolved: ResolvedConfig,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
baselineBySession: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
): Promise<ReconciledInstructionContext | undefined> {
const session = agent.session
const pending = pendingChangesFor(session, pendingBySession)
const visible = visibleInstructionChanges(agent, pending)
const effective = new Map(baselineBySession.get(session) ?? [])
for (const [scope, change] of visible) effective.set(scope, change)
const effective = visibleInstructionChanges(agent, pending)
/* v8 ignore next -- normal agents carry an absolute session cwd. */
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 scopes = new Set<string>()
const addDirScopes = (directory: string): void => {
for (const candidate of resolved.instructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
for (const candidate of resolved.localInstructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
const baselineScopes = new Set<string>()
const addDirScopes = (target: Set<string>, directory: string): void => {
for (const candidate of resolved.instructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
for (const candidate of resolved.localInstructionFileCandidates) target.add(candidateScopeKey(directory, candidate))
}
const addProjectScopes = (dir: string): void => {
addDirScopes(relativeScope(projectRoot, dir))
const addProjectScopes = (target: Set<string>, dir: string): void => {
addDirScopes(target, relativeScope(projectRoot, dir))
}
baselineScopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(baselineScopes, dir)
if (options.includeBaselineScopes) {
scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir)
for (const scope of baselineScopes) scopes.add(scope)
}
for (const scope of effective.keys()) {
if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue
const { directory } = decodeScopeKey(scope)
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
else addDirScopes(directory)
else addDirScopes(scopes, directory)
}
if (options.touchedPath !== undefined) {
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir)
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(scopes, dir)
}
const versions = versionStatesFor(session, versionCache)
@@ -530,7 +534,7 @@ export async function reconcileInstructionContext(
* @param result - original tool result before post-execute decisions.
* @param resolved - normalized plugin configuration.
* @param pendingNestedChanges - per-session pending transition maps.
* @param baselineInstructionStates - retained baseline comparison state.
* @param baselineSessions - sessions whose configured baseline scopes should be probed.
* @param versionCache - per-session scope metadata used to skip unchanged reads.
* @param fileSystem - provider used for current file probes.
* @returns rendered context plus deferred cache updates, or undefined for irrelevant/failed/unchanged calls.
@@ -541,7 +545,7 @@ export async function dynamicInstructionContext(
result: ToolExecutionResult,
resolved: ResolvedConfig,
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
baselineSessions: WeakSet<object>,
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
): Promise<ReconciledInstructionContext | undefined> {
@@ -549,10 +553,10 @@ export async function dynamicInstructionContext(
const touchedPath = filePathFromExecution(exec)
if (touchedPath === undefined) return undefined
return reconcileInstructionContext(
agent, resolved, pendingNestedChanges, baselineInstructionStates, versionCache, fileSystem,
agent, resolved, pendingNestedChanges, versionCache, fileSystem,
{
touchedPath,
includeBaselineScopes: baselineInstructionStates.has(agent.session),
includeBaselineScopes: baselineSessions.has(agent.session),
signal: exec.signal,
},
)

View File

@@ -78,7 +78,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
it('obeys a probe instruction loaded from the workspace', async () => {
const live = await harness()
live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }])
live.agent.followup({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } })
await waitForIdle(live.ctx, live.agent)
expect(finalText([...live.agent.session.events])).toContain(PROBE)
@@ -90,7 +90,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`)
await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n')
live.agent.followup([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }])
live.agent.followup({ content: [{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }], source: { kind: 'user' } })
await waitForIdle(live.ctx, live.agent)
expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE)
@@ -99,20 +99,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => {
const live = await harness()
await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n')
live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }])
live.agent.followup({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } })
await waitForIdle(live.ctx, live.agent)
await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`)
live.agent.followup([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }])
live.agent.followup({ content: [{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }], source: { kind: 'user' } })
await waitForIdle(live.ctx, live.agent)
const events = [...live.agent.session.events]
const update = events.find(event => event.type === 'user/message'
&& typeof event.data.meta === 'object'
&& event.data.meta !== null
&& !Array.isArray(event.data.meta)
&& event.data.meta.kind === 'workspace-instructions')
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.baseline !== true)
expect(update?.type === 'user/message' && update.data.source).toMatchObject({
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
const updateText = update?.type === 'user/message'

View File

@@ -6,8 +6,8 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentMessageId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessageData } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
@@ -177,15 +177,11 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
options: {},
session,
status: 'idle',
acceptsNextStep: false,
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
...options?.meta !== undefined ? { meta: options.meta } : {},
}, { surfaceOp: 'append' })
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
send: () => AgentMessageId('stub'),
@@ -205,30 +201,34 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri
return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? ''
}
function workspaceContextOf(result: { additionalContexts?: HookContext[] }): HookContext | undefined {
function workspaceContextOf(result: { additionalContexts?: UserMessageData[] }): UserMessageData | undefined {
return result.additionalContexts?.find(context =>
context.source.kind === 'plugin' && context.source.plugin === 'workspace-context')
context.source.kind === 'workspace-instructions')
}
function workspaceChangeContext(scope: string, digest: string): HookContext {
function baselineEvents(agent: Agent): SessionEvent[] {
return agent.session.events.filter(event =>
event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.baseline === true)
}
function workspaceChangeContext(scope: string, digest: string): UserMessageData {
return {
content: [{ type: 'text', text: `instructions for ${scope}` }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: {
source: {
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }],
},
}
}
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined {
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: UserMessageData[] }): number | undefined {
let lastSeq: number | undefined
for (const context of result.additionalContexts ?? []) {
lastSeq = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' }).seq
}
return lastSeq
@@ -237,11 +237,8 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H
const composedPrefixes = new WeakMap<object, Message[]>()
async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Message[]> {
const empty: Message[] = []
const prefix = await ctx.waterfall(
'agent/session-prefix', agent, empty, AbortSignal.timeout(1000),
() => Promise.resolve(empty),
)
await agentEvents(ctx, agent).serial('agent/step', 1, 1, AbortSignal.timeout(1000))
const prefix = agent.session.deriveMessages()
composedPrefixes.set(agent, prefix)
return prefix
}
@@ -931,7 +928,7 @@ describe('workspace context request injection', () => {
kind: 'accept' as const,
}))
expect(accepted.kind).toBe('accept')
expect(workspaceContextOf(accepted)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(accepted)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule')
} finally {
await ctx.fiber.dispose()
@@ -940,7 +937,7 @@ describe('workspace context request injection', () => {
}
})
it('contributes baseline instructions through the frozen session prefix instead of durable history', async () => {
it('contributes baseline instructions through durable injected history', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -952,7 +949,17 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(agent.session.deriveMessages()).toEqual([])
expect(baselineEvents(agent)).toHaveLength(1)
expect(baselineEvents(agent)[0]).toMatchObject({
type: 'user/message',
data: {
source: {
kind: 'workspace-instructions',
baseline: true,
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
},
},
})
expect(composedPrefixes.get(agent)).toHaveLength(1)
expect(derivedText(agent)).toContain('<system-reminder>')
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
@@ -965,7 +972,7 @@ describe('workspace context request injection', () => {
}
})
it('returns one baseline contribution per session-prefix composition without appending context events', async () => {
it('injects one durable baseline contribution on the first step only', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -979,7 +986,7 @@ describe('workspace context request injection', () => {
const second = await composeBaselinePrefix(ctx, agent)
expect(second).toEqual(first)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
expect(derivedText(agent)).toContain('repo rule')
} finally {
await rm(root, { recursive: true, force: true })
@@ -987,6 +994,116 @@ describe('workspace context request injection', () => {
}
})
it('retains a visible baseline after a plugin remount', 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()
const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
// Hot remount over the live session: the durable baseline remains
// visible, so the fresh mount does not append a duplicate.
await fiber.dispose()
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
await composeBaselinePrefix(ctx, agent)
expect(baselineEvents(agent)).toHaveLength(1)
await write(join(root, 'AGENTS.md'), 'updated repo rule')
const update = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-remount'),
name: 'read',
arguments: { file_path: 'file.txt' },
agent,
})
expect(workspaceContextOf(update)?.source).toMatchObject({
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('restores a compacted baseline on a hot plugin remount', 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 ctx.plugin(LocalFileSystem, { cwd: '/' })
const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
const baseline = baselineEvents(agent)[0]
expect(baseline).toBeDefined()
agent.session.append('user/message', {
content: [{ type: 'text', text: 'compacted summary' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
sourceEventSeqs: [baseline!.seq],
})
await fiber.dispose()
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
await composeBaselinePrefix(ctx, agent)
expect(baselineEvents(agent)).toHaveLength(2)
expect(blocksText(agent.session.deriveMessages().at(-1)?.content)).toContain('repo rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('recomposes the baseline from current files when a resumed session edited it offline', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'old root rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
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))
.toContain('old root rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('tracks only baseline files that were actually included under the byte budget', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -1009,7 +1126,7 @@ describe('workspace context request injection', () => {
}
})
it('places workspace instructions before later session-prefix contributors such as a skills catalog', async () => {
it('places workspace instructions before later step contributors such as a skills catalog', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -1017,9 +1134,8 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
const rest = await next()
return [{ role: 'user', content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }] }, ...rest]
ctx.on('agent/step', (agent) => {
agent.inject({ content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }], source: { kind: 'plugin', plugin: 'test-skills' } })
})
const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
@@ -1051,7 +1167,7 @@ describe('workspace context request injection', () => {
callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md')
@@ -1080,7 +1196,7 @@ describe('workspace context request injection', () => {
callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md')
@@ -1151,7 +1267,9 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind !== 'user',
)).toHaveLength(1)
expect(derivedText(agent)).not.toContain('workspace-context:')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1274,7 +1392,7 @@ describe('workspace context request injection', () => {
}
})
it('aborts an in-flight baseline stream with the session-prefix signal', async () => {
it('aborts an in-flight baseline stream with the step signal', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
@@ -1286,11 +1404,7 @@ describe('workspace context request injection', () => {
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const controller = new AbortController()
const reason = new Error('cancel prefix')
const empty: Message[] = []
const pending = ctx.waterfall(
'agent/session-prefix', stubAgent(root), empty, controller.signal,
() => Promise.resolve(empty),
)
const pending = agentEvents(ctx, stubAgent(root)).serial('agent/step', 1, 1, controller.signal)
await fs.started.promise
controller.abort(reason)
@@ -1520,7 +1634,7 @@ describe('workspace context request injection', () => {
}
})
it('cleans up its agent/session-prefix listener when the plugin fiber is disposed', async () => {
it('cleans up its agent/step listener when the plugin fiber is disposed', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -1717,16 +1831,18 @@ describe('dynamic nested workspace context injection', () => {
},
}))
agent.followup([{ type: 'text', text: 'read and abort' }])
agent.followup({ content: [{ type: 'text', text: 'read and abort' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
expect(agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind !== 'user',
)).toHaveLength(0)
agent.followup([{ type: 'text', text: 'retry the read' }])
agent.followup({ content: [{ type: 'text', text: 'retry the read' }], source: { kind: 'user' } })
await agent.whenIdle()
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
// The aborted batch drained its accepted context before step close, so the
// retry sees durable history without producing a duplicate instruction.
// Cancellation discards the aborted step's pending context. The next
// successful read discovers and durably injects it once.
expect(contexts).toHaveLength(1)
expect(adapter.requests).toHaveLength(3)
expect(adapter.requests[2]?.messages.map(blocks => blocksText(blocks.content)).join('\n'))
@@ -1811,19 +1927,18 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(workspaceContextOf(result)?.source).toMatchObject({
kind: 'workspace-instructions',
version: 1,
changes: [{
action: 'set',
scope: sk('pkg', 'AGENTS.md'),
path: join('pkg', 'AGENTS.md'),
}],
})
const meta = workspaceContextOf(result)?.meta
const firstChange = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes)
? meta.changes[0]
const source = workspaceContextOf(result)?.source
const firstChange = source?.kind === 'workspace-instructions'
? source.changes[0]
: undefined
const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange)
? firstChange.digest
@@ -1902,9 +2017,9 @@ describe('dynamic nested workspace context injection', () => {
agent: stubAgent(root),
})
const meta = workspaceContextOf(result)?.meta
const changes = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes)
? meta.changes
const source = workspaceContextOf(result)?.source
const changes = source?.kind === 'workspace-instructions'
? source.changes
: []
expect(changes).toEqual(expect.arrayContaining([
expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.md') }),
@@ -1999,6 +2114,7 @@ describe('dynamic nested workspace context injection', () => {
const instructionPath = join(root, 'pkg/AGENTS.md')
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(instructionPath, { type: 'file', content: 'nested package rule' })
fs.omitSizes.add(instructionPath)
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
await ctx.plugin(ToolFs)
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
@@ -2123,7 +2239,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(changed)?.meta).toMatchObject({
expect(workspaceContextOf(changed)?.source).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
@@ -2169,7 +2285,7 @@ describe('dynamic nested workspace context injection', () => {
})
// Removing one candidate only removes its own scope; the sibling scope is untouched.
expect(workspaceContextOf(removed)?.meta).toMatchObject({
expect(workspaceContextOf(removed)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`)
@@ -2197,7 +2313,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent,
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
const text = blocksText(workspaceContextOf(result)?.content)
@@ -2277,7 +2393,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(converged)?.meta).toMatchObject({
expect(workspaceContextOf(converged)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }],
})
expect(blocksText(workspaceContextOf(converged)?.content)).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`)
@@ -2311,7 +2427,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(converged)?.meta).toMatchObject({
expect(workspaceContextOf(converged)?.source).toMatchObject({
changes: [
{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') },
{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') },
@@ -2348,9 +2464,8 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(removed)?.meta).toEqual({
expect(workspaceContextOf(removed)?.source).toEqual({
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
@@ -2395,7 +2510,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(removed)?.meta).toMatchObject({
expect(workspaceContextOf(removed)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`)
@@ -2434,7 +2549,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(restored)?.meta).toMatchObject({
expect(workspaceContextOf(restored)?.source).toMatchObject({
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
@@ -2538,7 +2653,7 @@ describe('dynamic nested workspace context injection', () => {
await composeBaselinePrefix(ctx, resumed)
const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user')
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
expect(update?.type === 'user/message' && update.data.source).toMatchObject({
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
@@ -2600,6 +2715,63 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('re-arms an unchanged baseline after compaction removes it from the surface', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'root rule')
await write(join(root, 'file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
const baseline = baselineEvents(agent)[0]
expect(baseline).toBeDefined()
const whileVisible = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-visible-baseline'),
name: 'read',
arguments: { file_path: 'file.txt' },
agent,
})
agent.session.append('user/message', {
content: [{ type: 'text', text: 'compacted summary' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
sourceEventSeqs: [baseline!.seq],
})
const rearmed = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-compacted-baseline'),
name: 'read',
arguments: { file_path: 'file.txt' },
agent,
})
appendAdditionalContexts(agent, rearmed)
const afterRearm = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-rearmed-baseline'),
name: 'read',
arguments: { file_path: 'file.txt' },
agent,
})
expect(whileVisible.additionalContexts).toBeUndefined()
expect(workspaceContextOf(rearmed)?.source).toMatchObject({
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(rearmed)?.content)).toContain('root rule')
expect(afterRearm.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('does not treat markdown headings inside instruction content as loaded instruction metadata', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -2692,31 +2864,23 @@ describe('dynamic nested workspace context injection', () => {
{ type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' },
{ type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' },
],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: {
source: {
kind: 'workspace-instructions',
version: 1,
changes: [
null,
{ action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') },
{ action: 'set', scope: 'pkg', path: 42 },
{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 42 },
],
},
} as never,
}, { surfaceOp: 'append' })
agent.session.append('user/message', {
content: [{ type: 'text', text: 'stale metadata version' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: { kind: 'workspace-instructions', version: 0, changes: [] },
source: { kind: 'workspace-instructions', changes: 'invalid' } as never,
}, { surfaceOp: 'append' })
agent.session.append('user/message', {
content: [{ type: 'text', text: 'foreign plugin context' }],
source: { kind: 'plugin', plugin: 'other' },
meta: {
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 'spoof' }],
},
}, { surfaceOp: 'append' })
const result = await ctx.tools.execute({
@@ -2885,8 +3049,8 @@ describe('dynamic nested workspace context injection', () => {
})
expect(blocksText(result.content)).toContain('downstream replacement')
expect(result.additionalContexts).toHaveLength(2)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(workspaceContextOf(result)?.source).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
@@ -3247,7 +3411,6 @@ describe('workspace context pending state', () => {
const otherWorkspaceEvent = agent.session.append('user/message', {
content: otherContext.content,
source: otherContext.source,
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions)
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
@@ -3256,7 +3419,6 @@ describe('workspace context pending state', () => {
const confirmed = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, confirmed, pending, versions)
@@ -3281,6 +3443,29 @@ describe('workspace context pending state', () => {
expect(versions.has(agent.session)).toBe(false)
})
it('keeps an unrelated scope\'s version fast path when a step-close discard empties only its own scope', () => {
const agent = stubAgent('/')
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>()
const versions: InstructionVersionCache = new WeakMap()
agent.session.append('step/start', { turn: 1, step: 1 })
commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
versions.set(agent.session, new Map([
['pkg', {
path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
}],
['other', {
path: join('other', 'AGENTS.md'), version: FsVersion('v2'), digest: 'two', trimmedDigest: 'two',
}],
]))
const ended = agent.session.append('step/end', { turn: 1, step: 1 })
observeInstructionSessionEvent(agent.session, ended, pending, versions)
expect(pending.has(agent.session)).toBe(false)
expect(versions.get(agent.session)?.has('pkg')).toBe(false)
expect(versions.get(agent.session)?.has('other')).toBe(true)
})
it('rolls back only the exact current transition and releases empty session state', () => {
const agent = stubAgent('/')
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>()
@@ -3291,6 +3476,13 @@ describe('workspace context pending state', () => {
expect(commitPendingInstructionContexts(agent, [{
content: [], source: { kind: 'plugin', plugin: 'workspace-context' },
}], pending)).toEqual([])
// A workspace-instructions source whose change list filters to nothing
// must not mint per-session pending state.
expect(commitPendingInstructionContexts(agent, [{
content: [],
source: { kind: 'workspace-instructions', changes: [] },
}], pending)).toEqual([])
expect(pending.has(agent.session)).toBe(false)
const committed = commitPendingInstructionContexts(agent, [
workspaceChangeContext('first', 'one'),