fix(project-instructions): load files through fs service

This commit is contained in:
Yichen Jiang
2026-07-03 11:56:58 +08:00
parent 19abd0a357
commit a52cac00b1
15 changed files with 358 additions and 45 deletions

View File

@@ -12,9 +12,10 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import * as ProjectInstructions from '@deepseek-ai/dsh-project-instructions'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const PROBE = 'DSH_PROJECT_INSTRUCTIONS_PROBE_BANANA'
const PROBE = 'banana-271828'
let ctx: Context | undefined
let workdir: string | undefined
@@ -29,13 +30,14 @@ afterEach(async () => {
async function harness(): Promise<{ ctx: Context; agent: Agent }> {
workdir = await mkdtemp(join(tmpdir(), 'dsh-project-instructions-e2e-'))
await mkdir(join(workdir, '.git'), { recursive: true })
await writeFile(join(workdir, 'AGENTS.md'), `For this repository, every assistant response must include exactly this probe token: ${PROBE}.\n`)
await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the project instruction handshake, reply with exactly this string and nothing else: ${PROBE}.\n`)
ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ProjectInstructions)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
@@ -75,7 +77,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('project instructions e2e: real m
it('obeys a probe instruction loaded from the workspace', async () => {
const live = await harness()
live.agent.send([{ type: 'text', text: 'Reply with the repository probe token only.' }])
live.agent.send([{ type: 'text', text: 'Project instruction handshake?' }])
await waitForIdle(live.ctx, live.agent)
expect(finalText([...live.agent.session.events])).toContain(PROBE)

View File

@@ -9,9 +9,17 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import {
apply,
Config as ProjectInstructionsConfig,
discoverBaselineInstructionFiles,
loadBaselineInstructions,
renderProjectInstructions,
@@ -27,6 +35,52 @@ async function write(path: string, content: string): Promise<void> {
await writeFile(path, content)
}
class RecordingFileSystem extends FileSystem {
entries = new Map<string, { type: FsInfo['type']; content?: string }>()
throwOnStat = new Set<string>()
readTargets: string[] = []
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
const absolute = join(opts?.cwd ?? '/', path)
return { inputPath: path, targetKey: FsTargetKey(absolute), displayPath: absolute }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
if (this.throwOnStat.has(target.targetKey)) throw new Error(`stat failed: ${target.displayPath}`)
const entry = this.entries.get(target.targetKey)
if (entry === undefined) return undefined
const info: FsInfo = {
version: FsVersion(`v:${target.targetKey}`),
type: entry.type,
}
if (entry.content !== undefined) info.size = Buffer.byteLength(entry.content, 'utf8')
return info
}
override async readText(target: FsTarget): Promise<string> {
this.readTargets.push(target.targetKey)
return this.entries.get(target.targetKey)?.content ?? ''
}
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
const content = await this.readText(target)
return (async function* () { yield content })()
}
override async writeText(_target: FsTarget, _content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> {
return { operation: 'update', version: FsVersion('unused') }
}
override async editText(_target: FsTarget, _edit: FsEditRequest): Promise<FsEditOutcome> {
return { replacements: 0, replaceAll: false, version: FsVersion('unused') }
}
}
async function mountProjectInstructions(ctx: Context, config: projectInstructions.Config): Promise<Awaited<ReturnType<Context['plugin']>>> {
await ctx.plugin(LocalFileSystem, { cwd: '/' })
return ctx.plugin(projectInstructions, config)
}
function stubAgent(cwd?: string): Agent {
const id = SessionId('s1')
const session = new Session(id, [], cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
@@ -439,7 +493,7 @@ describe('project instruction request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home })
await mountProjectInstructions(ctx, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
@@ -460,6 +514,169 @@ describe('project instruction request injection', () => {
}
})
it('loads instruction file content through ctx.fs instead of direct node reads', 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: 'file', content: 'ctx.fs rule' })
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(firstText(result.messages[0])).toContain('ctx.fs rule')
expect(firstText(result.messages[0])).not.toContain('node fs rule')
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('loads user-global and CLAUDE fallback content through ctx.fs', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(home, 'AGENTS.md'), 'node global rule')
await write(join(root, 'CLAUDE.md'), 'node claude 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(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' })
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' })
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(firstText(result.messages[0])).toContain('ctx global rule')
expect(firstText(result.messages[0])).toContain('ctx claude rule')
expect(firstText(result.messages[0])).not.toContain('node global rule')
expect(firstText(result.messages[0])).not.toContain('node claude rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('skips lstat-visible instruction files when ctx.fs reports a non-file target', 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' })
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
} 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()
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: 'file' })
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(firstText(result.messages[0])).toContain('## AGENTS.md')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('skips lstat-visible instruction files when ctx.fs cannot stat them', 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.throwOnStat.add(join(root, 'AGENTS.md'))
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('treats ctx.fs marker lookup failures as absent root markers', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.throwOnStat.add(join(root, '.git'))
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
await ctx.plugin(projectInstructions, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
}
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
expect(firstText(result.messages[0])).toContain('repo rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('keeps different session cwd instruction files isolated in one context', async () => {
const repoA = await tempRepo()
const repoB = await tempRepo()
@@ -470,7 +687,7 @@ describe('project instruction request injection', () => {
await write(join(repoA, 'AGENTS.md'), 'repo A only')
await write(join(repoB, 'AGENTS.md'), 'repo B only')
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home })
await mountProjectInstructions(ctx, { dshHome: home })
const requestA: GenerateOptions = { model: 'mock', messages: [{ role: 'user', content: [{ type: 'text', text: 'A' }] }] }
const requestB: GenerateOptions = { model: 'mock', messages: [{ role: 'user', content: [{ type: 'text', text: 'B' }] }] }
@@ -497,7 +714,8 @@ describe('project instruction request injection', () => {
await write(join(root, 'AGENTS.md'), 'root schema default rule')
await write(join(cwd, 'AGENTS.md'), 'child schema default rule')
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', Config: ProjectInstructionsConfig, apply }, {})
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(projectInstructions, {})
const request: GenerateOptions = { model: 'mock', messages: [{ role: 'user', content: [{ type: 'text', text: 'prompt' }] }] }
const result = await ctx.waterfall('agent/request', stubAgent(cwd), 1, 1, request, async () => request)
@@ -517,7 +735,7 @@ describe('project instruction request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
const fiber = await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home })
const fiber = await mountProjectInstructions(ctx, { dshHome: home })
await fiber.dispose()
const request: GenerateOptions = {
@@ -540,7 +758,7 @@ describe('project instruction request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home, baselineMaxBytes: 0 })
await mountProjectInstructions(ctx, { dshHome: home, baselineMaxBytes: 0 })
const request: GenerateOptions = {
model: 'mock',
@@ -562,7 +780,7 @@ describe('project instruction request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home, baselineMaxBytes: -1 })
await mountProjectInstructions(ctx, { dshHome: home, baselineMaxBytes: -1 })
const request: GenerateOptions = {
model: 'mock',
@@ -583,7 +801,7 @@ describe('project instruction request injection', () => {
try {
await mkdir(join(root, '.git'), { recursive: true })
const ctx = new Context()
await ctx.plugin({ name: 'project-instructions', apply }, { dshHome: home })
await mountProjectInstructions(ctx, { dshHome: home })
const request: GenerateOptions = {
model: 'mock',