Merge origin/master into project instruction files
This commit is contained in:
@@ -4,15 +4,15 @@ Project instruction file loader for the harness. It discovers the configured per
|
||||
|
||||
## Behavior
|
||||
|
||||
The plugin listens on the `agent/request` waterfall and reads instruction file content through the `ctx.fs` provider seam. It uses `ctx.fs.lstat` before `ctx.fs.resolve` so repository-owned instruction symlinks are skipped rather than followed across trust boundaries. It deliberately does not declare `fs` as a static dependency: `agent-core` can load the plugin in providerless app trees, and the plugin simply does nothing until a filesystem provider is present at request/tool time. For each request it derives the workspace from `agent.session.header.cwd`; if the session has no cwd, it falls back to `process.cwd()` for single-session local/stdio runs. It then finds the project root by walking upward until it sees `.git` as either a directory or a file, considers the ancestor chain from project root to cwd, and loads at most one instruction file per directory by checking `instructionFileCandidates` in order. With the default candidates, `AGENTS.md` wins and `CLAUDE.md` is a compatibility fallback.
|
||||
The plugin listens on the `agent/pre-step` checkpoint and reads instruction file content through the `ctx.fs` provider seam before the loop snapshots `deriveMessages()` for the next model request. It uses `ctx.fs.lstat` before `ctx.fs.resolve` so repository-owned instruction symlinks are skipped rather than followed across trust boundaries. It deliberately does not declare `fs` as a static dependency: `agent-core` can load the plugin in providerless app trees, and the plugin simply does nothing until a filesystem provider is present at request/tool time. For each request it derives the workspace from `agent.session.header.cwd`; if the session has no cwd, it falls back to `process.cwd()` for single-session local/stdio runs. It then finds the project root by walking upward until it sees `.git` as either a directory or a file, considers the ancestor chain from project root to cwd, and loads at most one instruction file per directory by checking `instructionFileCandidates` in order. With the default candidates, `AGENTS.md` wins and `CLAUDE.md` is a compatibility fallback.
|
||||
|
||||
The plugin also listens on `tools/post-execute` for successful structured filesystem touches from the first-party `read`, `write`, and `edit` tools. When one of those tools touches a descendant of the session cwd, the plugin checks the directories between the session cwd and the touched file for instruction files that are not already visible in session context, then attaches them as `additionalContext` so the loop records a durable `context/message` for the next model request. This intentionally follows file-tool touches, not shell `cd`: `dsh-bash-local` uses fresh shells per call, and parsing arbitrary shell commands for reached paths would be brittle.
|
||||
|
||||
User-global instructions live at `$DSH_HOME/AGENTS.md`; `$DSH_HOME` defaults to `~/.dsh`. A configured `~`, `~/...`, or Windows-style `~\...` prefix is expanded against the operating-system home directory before resolution. The user-global file name is harness-level and is not affected by `instructionFileCandidates`, which only controls per-directory project and nested discovery. The user-global file renders before project files, so deeper project files appear later in the context and can override broader guidance.
|
||||
|
||||
Baseline files are inserted as a synthetic user-role workspace-context message, not as provider system text and not as persisted session events. Nested files discovered after structured file tools run are inserted through the existing `context/message` path, so they persist with the session and resume like other plugin-provided context. Nested duplicate suppression is derived from the visible session surface plus a short pending window before the loop records `additionalContext`; if compaction removes a nested context message from the surface, a later structured file touch may re-load it so the next model request still sees the applicable guidance. The rendered envelope states that these files are workspace-provided guidance, lower authority than system/developer/direct user instructions, and must not override safety, permission, or secret-handling rules.
|
||||
Baseline files are inserted through `agent.inject()` as durable `context/message` entries before the request boundary, not as provider system text and not by mutating the frozen request. Nested files discovered after structured file tools run use the same `context/message` path via `additionalContext`, so both baseline and nested guidance persist with the session and resume like other plugin-provided context. Duplicate suppression is derived from the visible session surface plus, for nested tool-time loads, a short pending window before the loop records `additionalContext`; if compaction removes an instruction context message from the surface, a later pre-step or structured file touch may re-load it so the next model request still sees the applicable guidance. The rendered envelope states that these files are workspace-provided guidance, lower authority than system/developer/direct user instructions, and must not override safety, permission, or secret-handling rules.
|
||||
|
||||
The baseline hook currently runs for every `agent/request`, including maintenance model calls such as compaction summarization. `GenerateOptions` does not yet carry a request-kind marker, so the plugin cannot distinguish user-facing turns from summarization without brittle prompt sniffing. A future request marker should let prompt-context plugins opt out of maintenance calls deliberately.
|
||||
Because baseline loading runs on `agent/pre-step`, it only targets agent conversation requests. One-shot maintenance model calls such as compaction summarization do not pass through this checkpoint.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface Config {
|
||||
|
||||
The renderer keeps full text until the configured byte budget is exceeded. When it must trim, it preserves more-specific files first, drops whole less-specific files before truncating a more-specific file, and emits an HTML comment naming omitted and truncated files with byte counts.
|
||||
|
||||
Discovery re-walks the applicable ancestor chain on every request so newly created baseline files are noticed. File content is cached by normalized absolute path plus the provider's opaque file version and size; a changed signature causes a re-read. The discovery pass carries the file signature forward to the read pass, so a cache hit does not stat the same instruction file twice in one request. Nested instruction paths are de-duplicated from recorded session context rather than from the content cache, so cache eviction or repeated reads do not duplicate still-visible durable context.
|
||||
Discovery re-walks the applicable ancestor chain on every pre-step so newly created baseline files are noticed. File content is cached by normalized absolute path plus the provider's opaque file version and size; a changed signature causes a re-read. The discovery pass carries the file signature forward to the read pass, so a cache hit does not stat the same instruction file twice in one request. Instruction paths are de-duplicated from visible recorded session context rather than from the content cache, so cache eviction or repeated reads do not duplicate still-visible durable context.
|
||||
|
||||
## Non-goals
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { lstat, readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
@@ -481,10 +480,6 @@ export function renderProjectInstructions(files: LoadedInstructionFile[], option
|
||||
return { text, omitted, truncated }
|
||||
}
|
||||
|
||||
function workspaceContextMessage(text: string): Message {
|
||||
return { role: 'user', content: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
function workspaceContextHook(text: string): HookContext {
|
||||
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE }
|
||||
}
|
||||
@@ -531,7 +526,7 @@ function instructionDisplayPathsFromContextContent(content: readonly { type: str
|
||||
return paths
|
||||
}
|
||||
|
||||
function loadedNestedInstructionDisplayPaths(agent: Agent, pendingDisplayPaths: Set<string>): Set<string> {
|
||||
function visibleInstructionDisplayPaths(agent: Agent): { visible: Set<string>; logged: Set<string> } {
|
||||
const visibleSeqs = new Set(agent.session.surface.nodes.map(node => node.seq))
|
||||
const visible = new Set<string>()
|
||||
const logged = new Set<string>()
|
||||
@@ -543,6 +538,11 @@ function loadedNestedInstructionDisplayPaths(agent: Agent, pendingDisplayPaths:
|
||||
if (visibleSeqs.has(seq)) visible.add(displayPath)
|
||||
}
|
||||
}
|
||||
return { visible, logged }
|
||||
}
|
||||
|
||||
function loadedNestedInstructionDisplayPaths(agent: Agent, pendingDisplayPaths: Set<string>): Set<string> {
|
||||
const { visible, logged } = visibleInstructionDisplayPaths(agent)
|
||||
// The loop records returned additionalContext shortly after this plugin
|
||||
// returns it. Once the durable log contains that marker anywhere, clear the
|
||||
// temporary pending bit; load decisions still use visible surface state so
|
||||
@@ -591,10 +591,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = resolveConfig(config)
|
||||
const cache: InstructionContentCache = new Map()
|
||||
const pendingNestedDisplayPaths = new WeakMap<object, Set<string>>()
|
||||
ctx.on('agent/request', async (agent: Agent, _turn: number, _step: number, request: GenerateOptions, next) => {
|
||||
if (resolved.baselineMaxBytes <= 0 || !Number.isFinite(resolved.baselineMaxBytes)) return next()
|
||||
ctx.on('agent/pre-step', async (agent: Agent) => {
|
||||
if (resolved.baselineMaxBytes <= 0 || !Number.isFinite(resolved.baselineMaxBytes)) return
|
||||
const fileSystem = ctx.get('fs')
|
||||
if (fileSystem === undefined) return next()
|
||||
if (fileSystem === undefined) return
|
||||
/* v8 ignore next -- stdio compatibility fallback; tests avoid process.chdir() because cwd is process-global. */
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
const instructions = await loadBaselineInstructions({
|
||||
@@ -605,10 +605,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||
cache,
|
||||
}, fileSystem)
|
||||
if (instructions !== undefined) {
|
||||
request.messages = [workspaceContextMessage(instructions.text), ...request.messages]
|
||||
}
|
||||
return next()
|
||||
if (instructions === undefined) return
|
||||
const visibleDisplayPaths = visibleInstructionDisplayPaths(agent).visible
|
||||
const baselineDisplayPaths = instructionDisplayPathsFromText(instructions.text)
|
||||
if (baselineDisplayPaths.length > 0 && baselineDisplayPaths.every(path => visibleDisplayPaths.has(path))) return
|
||||
agent.inject(workspaceContextHook(instructions.text).content, { source: PLUGIN_SOURCE })
|
||||
})
|
||||
ctx.on('tools/post-execute', async (exec: ToolExecution, result: ToolExecutionResult, next): Promise<PostToolDecision> => {
|
||||
const downstream = await next()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as projectInstructions from '@deepseek-ai/dsh-project-instructions'
|
||||
import { CallId, type GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -122,17 +122,14 @@ function stubAgent(cwd?: string): Agent {
|
||||
status: 'idle',
|
||||
send() {},
|
||||
steer() {},
|
||||
inject() {},
|
||||
inject(content, options) {
|
||||
session.append('context/message', { content, source: options?.source ?? { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function firstText(message: GenerateOptions['messages'][number] | undefined): string | undefined {
|
||||
const block = message?.content[0]
|
||||
return block?.type === 'text' ? block.text : undefined
|
||||
}
|
||||
|
||||
function blocksText(blocks: { type: string; text?: string }[] | undefined): string {
|
||||
return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? ''
|
||||
}
|
||||
@@ -146,6 +143,18 @@ function appendAdditionalContext(agent: Agent, result: { additionalContext?: Hoo
|
||||
}, { surfaceOp: 'append' }).seq
|
||||
}
|
||||
|
||||
async function runBaselinePreStep(ctx: Context, agent: Agent): Promise<void> {
|
||||
await ctx.serial('agent/pre-step', agent, 1, 1, '', AbortSignal.timeout(1000))
|
||||
}
|
||||
|
||||
function derivedText(agent: Agent): string {
|
||||
return blocksText(agent.session.deriveMessages()[0]?.content)
|
||||
}
|
||||
|
||||
function expectNoDerivedMessages(agent: Agent): void {
|
||||
expect(agent.session.deriveMessages()).toEqual([])
|
||||
}
|
||||
|
||||
describe('project instruction discovery', () => {
|
||||
it('loads user-global first, then root-to-cwd project instructions using the default candidate order', async () => {
|
||||
const root = await tempRepo()
|
||||
@@ -275,14 +284,11 @@ describe('project instruction discovery', () => {
|
||||
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
|
||||
const ctx = new Context()
|
||||
await mountProjectInstructions(ctx, { dshHome: home })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -631,18 +637,15 @@ describe('project instruction request injection', () => {
|
||||
expect('inject' in projectInstructions).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves requests unchanged when no filesystem provider is present', async () => {
|
||||
it('does not inject baseline context when no filesystem provider is present', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(projectInstructions, {})
|
||||
const request: GenerateOptions = {
|
||||
model: 'mock',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
|
||||
}
|
||||
const agent = stubAgent('/virtual/repo')
|
||||
|
||||
const result = await ctx.waterfall('agent/request', stubAgent('/virtual/repo'), 1, 1, request, async () => request)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
@@ -673,7 +676,7 @@ describe('project instruction request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('prepends a synthetic user workspace-context message without mutating the system prompt', async () => {
|
||||
it('injects baseline workspace context through the session log before step derivation', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -681,20 +684,35 @@ describe('project instruction request injection', () => {
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
const ctx = new Context()
|
||||
await mountProjectInstructions(ctx, { dshHome: home })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const request: GenerateOptions = {
|
||||
model: 'mock',
|
||||
system: 'real system',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }],
|
||||
}
|
||||
const result = await ctx.waterfall('agent/request', stubAgent(root), 1, 1, request, async () => request)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(result.system).toBe('real system')
|
||||
expect(result.messages).toHaveLength(2)
|
||||
expect(result.messages[0]?.role).toBe('user')
|
||||
expect(firstText(result.messages[0])).toContain('<workspace-context source="project-instruction-files">')
|
||||
expect(firstText(result.messages[0])).toContain('repo rule')
|
||||
expect(result.messages[1]).toEqual({ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] })
|
||||
expect(agent.session.deriveMessages()).toHaveLength(1)
|
||||
expect(derivedText(agent)).toContain('<context source="plugin">')
|
||||
expect(derivedText(agent)).toContain('<workspace-context source="project-instruction-files">')
|
||||
expect(derivedText(agent)).toContain('repo rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not duplicate still-visible baseline workspace context on later pre-step checks', 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 mountProjectInstructions(ctx, { dshHome: home })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(agent.session.events.filter(event => event.type === 'context/message')).toHaveLength(1)
|
||||
expect(derivedText(agent)).toContain('repo rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -713,15 +731,12 @@ describe('project instruction request injection', () => {
|
||||
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 agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(firstText(result.messages[0])).toContain('ctx.fs rule')
|
||||
expect(firstText(result.messages[0])).not.toContain('node fs rule')
|
||||
expect(derivedText(agent)).toContain('ctx.fs rule')
|
||||
expect(derivedText(agent)).not.toContain('node fs rule')
|
||||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -739,14 +754,11 @@ describe('project instruction request injection', () => {
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' })
|
||||
await ctx.plugin(projectInstructions, { dshHome: home })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(firstText(result.messages[0])).toContain('provider-only rule')
|
||||
expect(derivedText(agent)).toContain('provider-only rule')
|
||||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -769,17 +781,14 @@ describe('project instruction request injection', () => {
|
||||
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 agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
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')
|
||||
expect(derivedText(agent)).toContain('ctx global rule')
|
||||
expect(derivedText(agent)).toContain('ctx claude rule')
|
||||
expect(derivedText(agent)).not.toContain('node global rule')
|
||||
expect(derivedText(agent)).not.toContain('node claude rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -798,14 +807,11 @@ describe('project instruction request injection', () => {
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
|
||||
await ctx.plugin(projectInstructions, { dshHome: home })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -825,14 +831,11 @@ describe('project instruction request injection', () => {
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
|
||||
fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file')
|
||||
await ctx.plugin(projectInstructions, { dshHome: home })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -851,14 +854,11 @@ describe('project instruction request injection', () => {
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' })
|
||||
await ctx.plugin(projectInstructions, { dshHome: home })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(firstText(result.messages[0])).toContain('## AGENTS.md')
|
||||
expect(derivedText(agent)).toContain('## AGENTS.md')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -877,14 +877,11 @@ describe('project instruction request injection', () => {
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.throwOnStat.add(join(root, 'AGENTS.md'))
|
||||
await ctx.plugin(projectInstructions, { dshHome: home })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -903,14 +900,11 @@ describe('project instruction request injection', () => {
|
||||
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 agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(firstText(result.messages[0])).toContain('repo rule')
|
||||
expect(derivedText(agent)).toContain('repo rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -928,16 +922,16 @@ describe('project instruction request injection', () => {
|
||||
await write(join(repoB, 'AGENTS.md'), 'repo B only')
|
||||
const ctx = new Context()
|
||||
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' }] }] }
|
||||
const agentA = stubAgent(repoA)
|
||||
const agentB = stubAgent(repoB)
|
||||
|
||||
const resultA = await ctx.waterfall('agent/request', stubAgent(repoA), 1, 1, requestA, async () => requestA)
|
||||
const resultB = await ctx.waterfall('agent/request', stubAgent(repoB), 1, 1, requestB, async () => requestB)
|
||||
await runBaselinePreStep(ctx, agentA)
|
||||
await runBaselinePreStep(ctx, agentB)
|
||||
|
||||
expect(firstText(resultA.messages[0])).toContain('repo A only')
|
||||
expect(firstText(resultA.messages[0])).not.toContain('repo B only')
|
||||
expect(firstText(resultB.messages[0])).toContain('repo B only')
|
||||
expect(firstText(resultB.messages[0])).not.toContain('repo A only')
|
||||
expect(derivedText(agentA)).toContain('repo A only')
|
||||
expect(derivedText(agentA)).not.toContain('repo B only')
|
||||
expect(derivedText(agentB)).toContain('repo B only')
|
||||
expect(derivedText(agentB)).not.toContain('repo A only')
|
||||
} finally {
|
||||
await rm(repoA, { recursive: true, force: true })
|
||||
await rm(repoB, { recursive: true, force: true })
|
||||
@@ -956,19 +950,19 @@ describe('project instruction request injection', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
await ctx.plugin(projectInstructions, {})
|
||||
const request: GenerateOptions = { model: 'mock', messages: [{ role: 'user', content: [{ type: 'text', text: 'prompt' }] }] }
|
||||
const agent = stubAgent(cwd)
|
||||
|
||||
const result = await ctx.waterfall('agent/request', stubAgent(cwd), 1, 1, request, async () => request)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(firstText(result.messages[0])).toContain('## AGENTS.md\n\nroot schema default rule')
|
||||
expect(firstText(result.messages[0])).toContain('## child/AGENTS.md\n\nchild schema default rule')
|
||||
expect(derivedText(agent)).toContain('## AGENTS.md\n\nroot schema default rule')
|
||||
expect(derivedText(agent)).toContain('## child/AGENTS.md\n\nchild schema default rule')
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('cleans up its agent/request listener when the plugin fiber is disposed', async () => {
|
||||
it('cleans up its agent/pre-step listener when the plugin fiber is disposed', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -977,14 +971,11 @@ describe('project instruction request injection', () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await mountProjectInstructions(ctx, { dshHome: home })
|
||||
await fiber.dispose()
|
||||
const agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -999,14 +990,11 @@ describe('project instruction request injection', () => {
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
const ctx = new Context()
|
||||
await mountProjectInstructions(ctx, { dshHome: home, baselineMaxBytes: 0 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -1021,14 +1009,11 @@ describe('project instruction request injection', () => {
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
const ctx = new Context()
|
||||
await mountProjectInstructions(ctx, { dshHome: home, baselineMaxBytes: -1 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -1042,14 +1027,11 @@ describe('project instruction request injection', () => {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
const ctx = new Context()
|
||||
await mountProjectInstructions(ctx, { dshHome: home })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
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)
|
||||
await runBaselinePreStep(ctx, agent)
|
||||
|
||||
expect(result.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'actual prompt' }] }])
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
|
||||
Reference in New Issue
Block a user