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:
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user