feat(workspace-context): load all instruction candidates, dedup by trimmed content, follow symlinks

Squashes feat/instruction-load-all-dedup and feat/allow-instruction-symlink.
This commit is contained in:
Turtle
2026-07-22 10:55:19 +08:00
parent dd55b2cc62
commit 87899ae161
21 changed files with 685 additions and 343 deletions
@@ -1,5 +1,5 @@
import { mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
@@ -7,9 +7,8 @@ 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, { agentEvents, type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
@@ -24,12 +23,7 @@ import type {
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import type {
PostToolDecision,
ToolExecution,
ToolExecutionResult,
ToolExecutionToken,
} from '@deepseek-ai/dsh-tools'
import type { ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import {
discoverBaselineInstructionFiles,
@@ -44,8 +38,12 @@ import {
type InstructionVersionCache,
type PendingInstructionChange,
} from '../src/state.ts'
import { candidateScopeKey } from '../src/render.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/** Per-candidate reconciliation scope key: directory paired with the file name. */
const sk = (directory: string, candidateName: string): string => candidateScopeKey(directory, candidateName)
const testToolSignal = new AbortController().signal
async function tempRepo(): Promise<string> {
@@ -59,9 +57,7 @@ async function write(path: string, content: string): Promise<void> {
class RecordingFileSystem extends FileSystem {
entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>()
lstatTypes = new Map<string, FsPathInfo['type']>()
throwOnStat = new Set<string>()
throwOnRead = new Set<string>()
omitSizes = new Set<string>()
readTargets: string[] = []
readTextTargets: string[] = []
@@ -70,7 +66,7 @@ class RecordingFileSystem extends FileSystem {
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
if (opts?.signal !== undefined) this.signals.push(opts.signal)
opts?.signal?.throwIfAborted()
const absolute = resolve(opts?.cwd ?? '/', path)
const absolute = join(opts?.cwd ?? '/', path)
return { targetKey: FsTargetKey(absolute), displayPath: absolute }
}
@@ -92,8 +88,6 @@ class RecordingFileSystem extends FileSystem {
if (signal !== undefined) this.signals.push(signal)
signal?.throwIfAborted()
const target = await this.resolve(path, { ...opts, ...signal === undefined ? {} : { signal } })
const lstatType = this.lstatTypes.get(target.targetKey)
if (lstatType !== undefined) return { version: FsVersion(`lstat:${target.targetKey}`), type: lstatType }
const info = await this.stat(target, signal)
if (info === undefined) return undefined
return {
@@ -114,7 +108,6 @@ class RecordingFileSystem extends FileSystem {
if (signal !== undefined) this.signals.push(signal)
signal?.throwIfAborted()
this.readTargets.push(target.targetKey)
if (this.throwOnRead.has(target.targetKey)) throw new Error(`read failed: ${target.displayPath}`)
const content = this.entries.get(target.targetKey)?.content ?? ''
return (async function* () {
const midpoint = Math.ceil(content.length / 2)
@@ -235,31 +228,14 @@ const composedPrefixes = new WeakMap<object, Message[]>()
async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Message[]> {
const empty: Message[] = []
const prefix = await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, AbortSignal.timeout(1000),
const prefix = await ctx.waterfall(
'agent/session-prefix', agent, empty, AbortSignal.timeout(1000),
() => Promise.resolve(empty),
)
composedPrefixes.set(agent, prefix)
return prefix
}
function toolEventCarrier(ctx: Context, exec: ToolExecution) {
return scopeTarget(ctx.get('tools') ?? ctx as unknown as ToolRegistry, exec.agent)
}
function postExecute(
ctx: Context,
exec: ToolExecution,
result: Readonly<ToolExecutionResult>,
next: () => Promise<PostToolDecision>,
): Promise<PostToolDecision> {
return ctx.waterfall(toolEventCarrier(ctx, exec), 'tools/post-execute', exec, result, next)
}
function emitToolResult(ctx: Context, exec: ToolExecution, result: Readonly<ToolExecutionResult>): void {
ctx.emit(toolEventCarrier(ctx, exec), 'tools/result', exec, result)
}
function derivedText(agent: Agent): string {
return blocksText(composedPrefixes.get(agent)?.[0]?.content)
}
@@ -284,7 +260,7 @@ describe('workspace context instruction discovery', () => {
}
})
it('loads user-global first, then root-to-cwd workspace instructions using the default candidate order', async () => {
it('loads user-global first, then every root-to-cwd candidate in precedence order', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -292,7 +268,7 @@ describe('workspace context instruction discovery', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(home, 'AGENTS.md'), 'global rules')
await write(join(root, 'AGENTS.md'), 'root agents')
await write(join(root, 'CLAUDE.md'), 'root claude ignored')
await write(join(root, 'CLAUDE.md'), 'root claude')
await write(join(root, 'packages/CLAUDE.md'), 'package claude')
await write(join(cwd, 'AGENTS.md'), 'app agents')
@@ -301,10 +277,11 @@ describe('workspace context instruction discovery', () => {
expect(files.map(file => file.displayPath)).toEqual([
'$DSH_HOME/AGENTS.md',
'AGENTS.md',
join('packages', 'CLAUDE.md'),
join('packages', 'app', 'AGENTS.md'),
'CLAUDE.md',
'packages/CLAUDE.md',
'packages/app/AGENTS.md',
])
expect(files.map(file => file.absolutePath)).not.toContain(join(root, 'CLAUDE.md'))
expect(files.map(file => file.absolutePath)).toContain(join(root, 'CLAUDE.md'))
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -406,44 +383,41 @@ describe('workspace context instruction discovery', () => {
}
})
it('skips a provider file whose read fails after a successful metadata probe', async () => {
it('skips a file that becomes unreadable after discovery without failing the request', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
try {
const cwd = join(root, 'pkg')
await mkdir(join(root, '.git'), { recursive: true })
await mkdir(cwd, { recursive: true })
const leaf = join(cwd, 'AGENTS.md')
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(leaf, { type: 'file', content: 'secret-ish rule' })
fs.throwOnRead.add(leaf)
await write(leaf, 'secret-ish rule')
await chmod(leaf, 0)
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }, fs)
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
expect(loaded).toBeUndefined()
expect(fs.readTargets).toEqual([leaf])
await chmod(leaf, 0o600)
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('rejects symlinked instruction files instead of following repository-controlled links', async () => {
it('follows a symlinked instruction file to its target content', async () => {
const root = await tempRepo()
const home = await tempRepo()
const outside = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(outside, 'secret.txt'), 'outside secret')
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
await write(join(outside, 'shared.md'), 'shared instruction body')
await symlink(join(outside, 'shared.md'), join(root, 'AGENTS.md'))
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home })
const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
expect(files).toEqual([])
expect(loaded).toBeUndefined()
expect(files.map(file => file.displayPath)).toContain('AGENTS.md')
expect(loaded?.text).toContain('shared instruction body')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -451,21 +425,21 @@ describe('workspace context instruction discovery', () => {
}
})
it('rejects symlinked instruction files through ctx.fs instead of following repository-controlled links', async () => {
it('follows a symlinked instruction file through ctx.fs to its target content', async () => {
const root = await tempRepo()
const home = await tempRepo()
const outside = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(outside, 'secret.txt'), 'outside secret')
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
await write(join(outside, 'shared.md'), 'shared provider instruction body')
await symlink(join(outside, 'shared.md'), join(root, 'AGENTS.md'))
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
expectNoDerivedMessages(agent)
expect(derivedText(agent)).toContain('shared provider instruction body')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -511,7 +485,7 @@ describe('workspace context instruction discovery', () => {
}
})
it('uses the configured instruction candidate order without hard-coding AGENTS.md priority', async () => {
it('loads every configured instruction candidate in configured order without hard-coding AGENTS.md priority', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -526,7 +500,7 @@ describe('workspace context instruction discovery', () => {
instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'],
})
expect(files.map(file => file.displayPath)).toEqual(['CLAUDE.local.md'])
expect(files.map(file => file.displayPath)).toEqual(['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -556,6 +530,12 @@ describe('workspace context instruction discovery', () => {
it('defaults dshHome and uses cwd itself as root when no project marker exists', async () => {
const root = await tempRepo()
const emptyHome = await tempRepo()
// Isolate the default-home fallback: blank DSH_HOME is treated as unset, and
// HOME points at an empty dir so the default ~/.dsh holds no global scope.
// Symlinks are now followed, so a real ~/.dsh/AGENTS.md would otherwise leak in.
vi.stubEnv('DSH_HOME', '')
vi.stubEnv('HOME', emptyHome)
try {
const cwd = join(root, 'child')
await mkdir(cwd, { recursive: true })
@@ -567,7 +547,9 @@ describe('workspace context instruction discovery', () => {
expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md'])
expect(files.map(file => file.absolutePath)).toEqual([join(cwd, 'AGENTS.md')])
} finally {
vi.unstubAllEnvs()
await rm(root, { recursive: true, force: true })
await rm(emptyHome, { recursive: true, force: true })
}
})
@@ -596,7 +578,6 @@ describe('workspace context instruction discovery', () => {
vi.resetModules()
vi.doMock('node:os', () => ({ homedir: () => home }))
vi.stubEnv('DSH_HOME', undefined)
const isolated = await import('@deepseek-ai/dsh-workspace-context')
const files = await isolated.discoverBaselineInstructionFiles({ cwd: root })
@@ -604,7 +585,6 @@ describe('workspace context instruction discovery', () => {
} finally {
vi.doUnmock('node:os')
vi.resetModules()
vi.unstubAllEnvs()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
@@ -871,7 +851,7 @@ describe('workspace context request injection', () => {
try {
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const decision = await postExecute(ctx, stubToolExecution({
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
signal: testToolSignal,
callId: CallId('no-fs-post-execute'),
name: 'read',
@@ -920,7 +900,7 @@ describe('workspace context request injection', () => {
}
// A later PostToolUse-style policy blocks this otherwise-successful read.
const blocked = await postExecute(ctx, exec, result, async () => ({
const blocked = await ctx.waterfall('tools/post-execute', exec, result, async () => ({
kind: 'block' as const,
feedback: [{ type: 'text' as const, text: 'blocked by policy' }],
}))
@@ -934,7 +914,7 @@ describe('workspace context request injection', () => {
// The same read, when the downstream accepts, DOES surface the nested
// instructions — proving the block branch above is what suppressed them,
// and that the block did not consume the pending nested change.
const accepted = await postExecute(ctx, exec, result, async () => ({
const accepted = await ctx.waterfall('tools/post-execute', exec, result, async () => ({
kind: 'accept' as const,
}))
expect(accepted.kind).toBe('accept')
@@ -1009,7 +989,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(derivedText(agent)).toContain('omitted AGENTS.md')
expect(derivedText(agent)).toContain(`Instructions from: ${join('pkg', 'AGENTS.md')}\n\npackage rule`)
expect(derivedText(agent)).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1059,7 +1039,7 @@ describe('workspace context request injection', () => {
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
changes: [{ action: 'replace', scope: '.', path: 'AGENTS.md' }],
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('new root rule with more detail')
@@ -1088,7 +1068,7 @@ describe('workspace context request injection', () => {
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
changes: [{ action: 'remove', scope: '.', path: 'AGENTS.md' }],
changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md')
} finally {
@@ -1120,6 +1100,32 @@ describe('workspace context request injection', () => {
}
})
it('deduplicates trimmed-identical sibling candidates in one directory and renders the earliest original bytes', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'shared repo rule')
await write(join(root, 'CLAUDE.md'), ' shared repo rule\n\n')
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 text = derivedText(agent)
expect(text.match(/shared repo rule/g)).toHaveLength(1)
expect(text).toContain('Instructions from: AGENTS.md')
expect(text).not.toContain('Instructions from: CLAUDE.md')
// The kept candidate's original bytes are rendered, not the whitespace-padded duplicate.
expect(text).not.toContain(' shared repo rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('does not expose state markers when a tiny budget reduces the baseline contribution', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -1268,9 +1274,8 @@ describe('workspace context request injection', () => {
const controller = new AbortController()
const reason = new Error('cancel prefix')
const empty: Message[] = []
const agent = stubAgent(root)
const pending = agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, controller.signal,
const pending = ctx.waterfall(
'agent/session-prefix', stubAgent(root), empty, controller.signal,
() => Promise.resolve(empty),
)
@@ -1337,30 +1342,6 @@ describe('workspace context request injection', () => {
}
})
it('skips provider-visible instruction candidates when ctx.fs stat disagrees after no-follow preflight', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'node fs rule')
const ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file')
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
expectNoDerivedMessages(agent)
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('loads instruction files when ctx.fs omits the metadata size', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -1407,7 +1388,7 @@ describe('workspace context request injection', () => {
}
})
it('does not fall through to a lower-priority candidate when the winning provider file becomes unavailable', async () => {
it('skips a candidate whose provider probe fails while still loading its available sibling', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -1415,16 +1396,16 @@ describe('workspace context request injection', () => {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file')
fs.throwOnStat.add(join(root, 'AGENTS.md'))
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'must not bypass AGENTS failure' })
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
expectNoDerivedMessages(agent)
expect(fs.readTargets).not.toContain(join(root, 'CLAUDE.md'))
expect(derivedText(agent)).toContain('claude sibling rule')
expect(fs.readTargets).toContain(join(root, 'CLAUDE.md'))
expect(fs.readTargets).not.toContain(join(root, 'AGENTS.md'))
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1498,7 +1479,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nroot schema default rule')
expect(derivedText(agent)).toContain(`Instructions from: ${join('child', 'AGENTS.md')}\n\nchild schema default rule`)
expect(derivedText(agent)).toContain('Instructions from: child/AGENTS.md\n\nchild schema default rule')
await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
@@ -1629,9 +1610,9 @@ describe('workspace context request injection', () => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
lstat: async (path: string) => {
stat: async (path: string) => {
observedStats.set(path, (observedStats.get(path) ?? 0) + 1)
return actual.lstat(path)
return actual.stat(path)
},
}
})
@@ -1649,22 +1630,22 @@ describe('workspace context request injection', () => {
}
})
it('does not bypass an unavailable host AGENTS.md with a lower-priority candidate', async () => {
it('skips an unavailable host candidate but still loads its available sibling', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'CLAUDE.md'), 'must not bypass unavailable AGENTS')
await write(join(root, 'CLAUDE.md'), 'claude host sibling rule')
vi.resetModules()
vi.doMock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
lstat: async (path: string) => {
stat: async (path: string) => {
if (path === join(root, 'AGENTS.md')) {
throw Object.assign(new Error('permission denied'), { code: 'EACCES' })
}
return actual.lstat(path)
return actual.stat(path)
},
}
})
@@ -1672,7 +1653,7 @@ describe('workspace context request injection', () => {
const rendered = await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
expect(rendered).toBeUndefined()
expect(rendered?.text).toContain('claude host sibling rule')
} finally {
vi.doUnmock('node:fs/promises')
vi.resetModules()
@@ -1718,7 +1699,7 @@ describe('dynamic nested workspace context injection', () => {
description: 'Abort the current test step.',
parameters: {},
async execute() {
agent.cancel({ kind: 'user' })
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('test abort')
return [{ type: 'text', text: 'aborted' }]
},
}))
@@ -1751,7 +1732,7 @@ describe('dynamic nested workspace context injection', () => {
content: 'root rule',
}])
const change = state.changes.get('.')
const change = state.changes.get(sk('.', 'AGENTS.md'))
expect(change).toMatchObject({
action: 'set',
path: 'AGENTS.md',
@@ -1781,7 +1762,7 @@ describe('dynamic nested workspace context injection', () => {
signal: controller.signal,
})
const pending = postExecute(ctx, exec, {
const pending = ctx.waterfall('tools/post-execute', exec, {
content: [{ type: 'text', text: 'ok' }],
isError: false,
}, () => Promise.resolve({ kind: 'accept' as const }))
@@ -1822,8 +1803,8 @@ describe('dynamic nested workspace context injection', () => {
version: 1,
changes: [{
action: 'set',
scope: 'pkg',
path: join('pkg', 'AGENTS.md'),
scope: sk('pkg', 'AGENTS.md'),
path: 'pkg/AGENTS.md',
}],
})
const meta = workspaceContextOf(result)?.meta
@@ -1837,7 +1818,7 @@ describe('dynamic nested workspace context injection', () => {
const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toBe([
'<system-reminder>',
`Additional instructions from: ${join('pkg', 'AGENTS.md')}`,
'Additional instructions from: pkg/AGENTS.md',
'',
'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.',
'',
@@ -1852,7 +1833,7 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('uses configured instruction candidates for nested discovery', async () => {
it('loads every configured instruction candidate present in a nested scope', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -1876,9 +1857,11 @@ describe('dynamic nested workspace context injection', () => {
})
const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`)
expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md')
expect(text).toContain('local package rule')
expect(text).not.toContain('native package rule')
expect(text).toContain('Additional instructions from: pkg/AGENTS.md')
expect(text).toContain('native package rule')
expect(text.indexOf('pkg/CLAUDE.local.md')).toBeLessThan(text.indexOf('pkg/AGENTS.md'))
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1898,6 +1881,7 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-nested-overlay'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -1939,6 +1923,7 @@ describe('dynamic nested workspace context injection', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-nested-overlay-disabled'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2126,11 +2111,11 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(changed)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(changed)?.content)).toBe([
'<system-reminder>',
`Updated instructions from: ${join('pkg', 'AGENTS.md')}`,
'Updated instructions from: pkg/AGENTS.md',
'',
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
'',
@@ -2143,13 +2128,13 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('replaces an AGENTS candidate with the configured fallback in the same scope', async () => {
it('reconciles distinct sibling candidates as independent scopes', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'native package rule')
await write(join(root, 'pkg/CLAUDE.md'), 'fallback package rule')
await write(join(root, 'pkg/CLAUDE.md'), 'sibling package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
@@ -2157,29 +2142,129 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
callId: CallId('read-both-siblings'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
const firstText = blocksText(workspaceContextOf(first)?.content)
expect(firstText).toContain('native package rule')
expect(firstText).toContain('sibling package rule')
appendAdditionalContexts(agent, first)
await rm(join(root, 'pkg/AGENTS.md'))
const changed = await ctx.tools.execute({
const removed = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, changed)
const unchanged = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-logged-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
callId: CallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(workspaceContextOf(changed)?.meta).toMatchObject({
changes: [{
action: 'replace', scope: 'pkg', path: join('pkg', 'CLAUDE.md'), previousPath: join('pkg', 'AGENTS.md'),
}],
// Removing one candidate only removes its own scope; the sibling scope is untouched.
expect(workspaceContextOf(removed)?.meta).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`Updated instructions from: ${join('pkg', 'CLAUDE.md')}`)
expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`The instructions previously loaded from \`${join('pkg', 'AGENTS.md')}\` no longer apply. Use the following content for \`pkg\` instead.`)
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('fallback package rule')
expect(unchanged.additionalContexts).toBeUndefined()
expect(blocksText(workspaceContextOf(removed)?.content)).toContain('Instructions removed: pkg/AGENTS.md')
expect(blocksText(workspaceContextOf(removed)?.content)).not.toContain('sibling package rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('drops a newly discovered sibling whose content duplicates an earlier candidate in the scope', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'nested rule')
await write(join(root, 'pkg/CLAUDE.md'), 'nested rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent,
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
})
const text = blocksText(workspaceContextOf(result)?.content)
expect(text.match(/nested rule/g)).toHaveLength(1)
expect(text).toContain('Additional instructions from: pkg/AGENTS.md')
expect(text).not.toContain('pkg/CLAUDE.md')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('removes a previously rendered sibling once its content becomes a duplicate of an earlier candidate', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'canonical nested rule')
await write(join(root, 'pkg/CLAUDE.md'), 'divergent nested rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
const firstText = blocksText(workspaceContextOf(first)?.content)
expect(firstText).toContain('canonical nested rule')
expect(firstText).toContain('divergent nested rule')
appendAdditionalContexts(agent, first)
await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule')
const converged = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(workspaceContextOf(converged)?.meta).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: 'pkg/CLAUDE.md' }],
})
expect(blocksText(workspaceContextOf(converged)?.content)).toContain('Instructions removed: pkg/CLAUDE.md')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('removes an unchanged sibling when an earlier candidate changes to match its content', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'primary nested rule')
await write(join(root, 'pkg/CLAUDE.md'), 'secondary nested rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, first)
// Only the earlier candidate changes; the sibling stays byte-identical but now duplicates it.
await write(join(root, 'pkg/AGENTS.md'), 'secondary nested rule')
const converged = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(workspaceContextOf(converged)?.meta).toMatchObject({
changes: [
{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' },
{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: 'pkg/CLAUDE.md' },
],
})
const text = blocksText(workspaceContextOf(converged)?.content)
expect(text).toContain('Instructions removed: pkg/CLAUDE.md')
expect(text).toContain('Updated instructions from: pkg/AGENTS.md')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2211,11 +2296,11 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(removed)?.meta).toEqual({
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'remove', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
'<system-reminder>',
`Instructions removed: ${join('pkg', 'AGENTS.md')}`,
'Instructions removed: pkg/AGENTS.md',
'',
'The previously loaded instructions from this file no longer apply.',
'</system-reminder>',
@@ -2226,6 +2311,45 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('removes a previously loaded instruction file once it resolves to a directory through a symlink', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, first)
expect(blocksText(workspaceContextOf(first)?.content)).toContain('package rule')
// The candidate now resolves through a symlink to a directory. A non-file
// target is a confirmed absence (not unavailable), so the loaded scope is
// removed; an unavailable classification would emit no change at all.
await rm(join(root, 'pkg/AGENTS.md'))
await mkdir(join(root, 'pkg/elsewhere'), { recursive: true })
await symlink(join(root, 'pkg/elsewhere'), join(root, 'pkg/AGENTS.md'))
const removed = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(workspaceContextOf(removed)?.meta).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toContain('Instructions removed: pkg/AGENTS.md')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('loads a candidate again after a logged removal tombstone', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -2256,9 +2380,9 @@ describe('dynamic nested workspace context injection', () => {
})
expect(workspaceContextOf(restored)?.meta).toMatchObject({
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md')
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule')
} finally {
await rm(root, { recursive: true, force: true })
@@ -2363,7 +2487,7 @@ describe('dynamic nested workspace context injection', () => {
const update = resumed.session.events.findLast(event => event.type === 'context/message')
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
})
expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
} finally {
@@ -2491,8 +2615,8 @@ describe('dynamic nested workspace context injection', () => {
})
const firstText = blocksText(workspaceContextOf(first)?.content)
expect(firstText).toContain(`omitted ${join('pkg', 'AGENTS.md')}`)
expect(firstText).not.toContain(`## ${join('pkg', 'AGENTS.md')}`)
expect(firstText).toContain('omitted pkg/AGENTS.md')
expect(firstText).not.toContain('## pkg/AGENTS.md')
expect(firstText).toContain('subtree rule')
expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule')
} finally {
@@ -2524,7 +2648,6 @@ describe('dynamic nested workspace context injection', () => {
null,
{ action: 'unknown', scope: 'pkg', path: 'pkg/AGENTS.md' },
{ action: 'set', scope: 'pkg', path: 42 },
{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md', previousPath: 42 },
{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 42 },
],
},
@@ -2594,7 +2717,7 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('treats provider failures and type disagreement after lstat as unavailable, not removed', async () => {
it('treats a reconciliation provider failure as unavailable and a resolved non-file as absent', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
@@ -2602,7 +2725,6 @@ describe('dynamic nested workspace context injection', () => {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.lstatTypes.set(join(root, 'pkg/AGENTS.md'), 'file')
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
@@ -2612,13 +2734,13 @@ describe('dynamic nested workspace context injection', () => {
isError: false,
}
const failedStat = await postExecute(ctx, stubToolExecution({
const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
signal: testToolSignal,
callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
}), result, async () => ({ kind: 'accept' as const }))
fs.throwOnStat.clear()
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' })
const mismatchedStat = await postExecute(ctx, stubToolExecution({
const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
signal: testToolSignal,
callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
}), result, async () => ({ kind: 'accept' as const }))
@@ -2635,19 +2757,14 @@ describe('dynamic nested workspace context injection', () => {
it('skips unreadable nested instruction files without attaching empty context', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
const nested = join(root, 'pkg/AGENTS.md')
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(nested, { type: 'file', content: 'nested package rule' })
fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' })
fs.throwOnRead.add(nested)
await ctx.plugin(ToolFs)
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
await write(nested, 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
await chmod(nested, 0)
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({
signal: testToolSignal,
@@ -2659,9 +2776,8 @@ describe('dynamic nested workspace context injection', () => {
expect(result.isError).toBe(false)
expect(result.additionalContexts).toBeUndefined()
expect(fs.readTargets).toContain(nested)
await chmod(nested, 0o600)
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
@@ -2698,7 +2814,7 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context')
@@ -2816,12 +2932,13 @@ describe('dynamic nested workspace context injection', () => {
parameters: {},
async execute(_args, exec) {
const nested = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`${exec.callId}:nested`),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
...exec.agent === undefined ? {} : { agent: exec.agent },
parent: exec.token,
signal: exec.signal,
...exec.signal === undefined ? {} : { signal: exec.signal },
})
for (const context of nested.additionalContexts ?? []) exec.deferContext(context)
return nested.content
@@ -2866,23 +2983,23 @@ describe('dynamic nested workspace context injection', () => {
const parent = Symbol('parent') as ToolExecutionToken
const plainResult = { callId: CallId('plain'), content: [], isError: false }
emitToolResult(ctx, stubToolExecution({
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('agentless-child'), name: 'read', arguments: {}, parent,
}), plainResult)
emitToolResult(ctx, stubToolExecution({
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent,
}), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] })
emitToolResult(ctx, stubToolExecution({
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent,
}), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] })
emitToolResult(ctx, stubToolExecution({
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent,
}), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] })
emitToolResult(ctx, {
ctx.emit('tools/result', {
...stubToolExecution({ signal: testToolSignal, callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
token: parent,
}, plainResult)
@@ -2918,7 +3035,7 @@ describe('dynamic nested workspace context injection', () => {
]
for (const item of cases) {
const decision = await postExecute(ctx, stubToolExecution({
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
signal: testToolSignal,
callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`),
name: item.name,
@@ -3042,7 +3159,7 @@ describe('workspace context pending state', () => {
const [change] = commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
expect(change).toBeDefined()
versions.set(agent.session, new Map([['pkg', {
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one',
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
}]]))
const unrelated = agent.session.append('context/message', {
@@ -3079,7 +3196,7 @@ describe('workspace context pending state', () => {
agent.session.append('step/start', { turn: 1, step: 1 })
commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
versions.set(agent.session, new Map([['pkg', {
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one',
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
}]]))
const ended = agent.session.append('step/end', { turn: 1, step: 1 })