mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Local run of check:ci:windows-complete (the windows-native gate) on latest master surfaced five Windows-only failures, all unreachable by current CI because the native windows job is disabled and the wine gate only covers build+site. - install-lefthook/translation-pairing-merge specs junctioned the real scripts/ and tsx package into fixtures; Windows recursive deletion (Node rmSync and git worktree remove) follows MOUNT_POINT junctions and deleted the repository's own directories mid-run. Fixtures now unlink their reparse points before any recursive removal (shared helper in scripts/test-fixture-cleanup.ts). - workflow-workerthread spawned its worker with an empty env; on Windows os.tmpdir() then degrades to the literal relative path undefined\temp, so tsx wrote its transform cache into a cwd-relative undefined/ directory inside the repo. The worker env now injects the host temp path on win32 (workerSpawnEnv, platform-parameterized and unit-tested on both arms). - workspace-context spec did not stub USERPROFILE (win32 homedir) or a set DSH_HOME, leaking the developer machine's real ~/.dsh/AGENTS.md into discovery. - ui-trajectory client-bundle spec mounted the built artifact without the remote/settingsScope provides the locale plugin needs, so the plugin never activated and no view registered. - subagent temp-fixture cleanup lacked the maxRetries Windows handle release needs under load (EPERM); added retries to the three affected specs and the fixture-cleanup helper.
4649 lines
193 KiB
TypeScript
4649 lines
193 KiB
TypeScript
import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
|
||
import { dirname, join, resolve } from 'node:path'
|
||
import { tmpdir } from 'node:os'
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import { Context } from '@deepseek-ai/cordis'
|
||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||
import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions'
|
||
import LlmRuntime, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
|
||
import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
|
||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||
import type {
|
||
FsDirEntry,
|
||
FsEditOutcome,
|
||
FsEditRequest,
|
||
FsInfo,
|
||
FsPathInfo,
|
||
FsTarget,
|
||
FsWriteIntent,
|
||
FsWriteOutcome,
|
||
} from '@deepseek-ai/dsh-fs'
|
||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||
import ToolRuntime, { defineContentToolFixture } 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,
|
||
loadBaselineInstructions,
|
||
renderWorkspaceContext,
|
||
} from '@deepseek-ai/dsh-agent-instructions'
|
||
import {
|
||
applyInstructionVersionUpdates,
|
||
baselineInstructionState,
|
||
reconcileInstructionContext,
|
||
type InstructionVersionCache,
|
||
} from '../src/state.ts'
|
||
import { resolveConfig } from '../src/config.ts'
|
||
import { candidateScopeKey, renderInstructionChanges, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } 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> {
|
||
return mkdtemp(join(tmpdir(), 'dsh-workspace-context-'))
|
||
}
|
||
|
||
async function write(path: string, content: string): Promise<void> {
|
||
await mkdir(join(path, '..'), { recursive: true })
|
||
await writeFile(path, content)
|
||
}
|
||
|
||
class RecordingFileSystem extends FileSystem {
|
||
entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>()
|
||
throwOnStat = new Set<string>()
|
||
throwOnRead = new Set<string>()
|
||
omitSizes = new Set<string>()
|
||
readTargets: string[] = []
|
||
readTextTargets: string[] = []
|
||
signals: AbortSignal[] = []
|
||
|
||
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
|
||
if (opts?.signal !== undefined) this.signals.push(opts.signal)
|
||
opts?.signal?.throwIfAborted()
|
||
// resolve(), not join(): entries are seeded with host join() keys, and on
|
||
// Windows a joined '/'-rooted prefix would not match a resolved drive path.
|
||
const absolute = resolve(opts?.cwd ?? '/', path)
|
||
return { targetKey: FsTargetKey(absolute), displayPath: absolute }
|
||
}
|
||
|
||
override processPath(target: FsTarget): string { return String(target.targetKey) }
|
||
|
||
override fileUrl(target: FsTarget): string { return `file://${target.targetKey}` }
|
||
|
||
override contains(parent: FsTarget, child: FsTarget): boolean {
|
||
return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`)
|
||
}
|
||
|
||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||
if (signal !== undefined) this.signals.push(signal)
|
||
signal?.throwIfAborted()
|
||
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: entry.version ?? FsVersion(`v:${target.targetKey}:${entry.type}:${entry.content ?? ''}`),
|
||
type: entry.type,
|
||
}
|
||
if (entry.content !== undefined && !this.omitSizes.has(target.targetKey)) info.size = Buffer.byteLength(entry.content, 'utf8')
|
||
return info
|
||
}
|
||
|
||
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
|
||
if (signal !== undefined) this.signals.push(signal)
|
||
signal?.throwIfAborted()
|
||
const target = await this.resolve(path, { ...opts, ...signal === undefined ? {} : { signal } })
|
||
const info = await this.stat(target, signal)
|
||
if (info === undefined) return undefined
|
||
return {
|
||
version: info.version,
|
||
type: info.type,
|
||
...(info.size !== undefined ? { size: info.size } : {}),
|
||
}
|
||
}
|
||
|
||
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
|
||
if (signal !== undefined) this.signals.push(signal)
|
||
signal?.throwIfAborted()
|
||
this.readTextTargets.push(target.targetKey)
|
||
return this.entries.get(target.targetKey)?.content ?? ''
|
||
}
|
||
|
||
override async readBytes(_target: FsTarget, _signal: AbortSignal | undefined, _maxBytes: number): Promise<Uint8Array> {
|
||
throw new Error('not needed in agent-instructions tests')
|
||
}
|
||
|
||
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||
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)
|
||
yield content.slice(0, midpoint)
|
||
signal?.throwIfAborted()
|
||
yield content.slice(midpoint)
|
||
})()
|
||
}
|
||
|
||
override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
|
||
return []
|
||
}
|
||
|
||
override async writeText(_target: FsTarget, _content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> {
|
||
return { operation: 'update', version: FsVersion('unused'), before: '', after: _content }
|
||
}
|
||
|
||
override async editText(_target: FsTarget, _edit: FsEditRequest): Promise<FsEditOutcome> {
|
||
return { version: FsVersion('unused'), before: '', after: '' }
|
||
}
|
||
}
|
||
|
||
class BlockingReadFileSystem extends RecordingFileSystem {
|
||
readonly started = Promise.withResolvers<undefined>()
|
||
|
||
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||
if (signal !== undefined) this.signals.push(signal)
|
||
this.readTargets.push(target.targetKey)
|
||
this.started.resolve(undefined)
|
||
return (async function* () {
|
||
await new Promise<void>((_resolve, reject) => {
|
||
const abortReason = (): Error => signal?.reason instanceof Error ? signal.reason : new Error('aborted')
|
||
if (signal?.aborted) { reject(abortReason()); return }
|
||
signal?.addEventListener('abort', () => { reject(abortReason()) }, { once: true })
|
||
})
|
||
yield 'unreachable'
|
||
})()
|
||
}
|
||
}
|
||
|
||
async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise<Awaited<ReturnType<Context['plugin']>>> {
|
||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||
return ctx.plugin(workspaceContext, config)
|
||
}
|
||
|
||
async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise<Awaited<ReturnType<Context['plugin']>>> {
|
||
await ctx.plugin(SystemPrompt)
|
||
await ctx.plugin(ToolRuntime)
|
||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||
await ctx.plugin(ToolFs)
|
||
return ctx.plugin(workspaceContext, config)
|
||
}
|
||
|
||
function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||
const id = SessionId('s1')
|
||
const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
|
||
return {
|
||
ctx: new Context(),
|
||
id: SessionId('a1'),
|
||
options: {},
|
||
session,
|
||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||
status: 'idle',
|
||
send: () => {},
|
||
followup: () => {},
|
||
steer: () => {},
|
||
inject: () => { throw new Error('agent-instructions must append directly to the open step') },
|
||
cancel() {},
|
||
runMaintenance: task => task(new AbortController().signal),
|
||
whenIdle: () => Promise.resolve(),
|
||
}
|
||
}
|
||
|
||
function stubToolExecution(
|
||
input: Omit<ToolExecution, 'token' | 'rootCallId'> & {
|
||
token?: ToolExecutionToken
|
||
rootCallId?: ToolExecution['rootCallId']
|
||
},
|
||
): ToolExecution {
|
||
return {
|
||
token: input.token ?? Symbol('workspace-context-test-execution') as ToolExecutionToken,
|
||
...input,
|
||
rootCallId: input.rootCallId ?? input.callId,
|
||
}
|
||
}
|
||
|
||
function blocksText(blocks: { type: string; text?: string }[] | undefined): string {
|
||
return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? ''
|
||
}
|
||
|
||
async function workspaceContextOf(agent: Agent): Promise<UserMessage> {
|
||
return vi.waitFor(() => {
|
||
const context = agent.inbox.nextStep.find(message =>
|
||
message.source.kind === 'agent-instructions')
|
||
expect(context).toBeDefined()
|
||
return context!
|
||
})
|
||
}
|
||
|
||
async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise<void> {
|
||
await agentEvents(ctx, agent).waterfall(
|
||
'agent/pre-step', { messages: [], turn: 1, step: 1, signal: testToolSignal },
|
||
async () => ({ kind: 'enter' as const, messages: [] }),
|
||
)
|
||
}
|
||
|
||
async function syncedWorkspaceContext(ctx: Context, agent: Agent): Promise<UserMessage> {
|
||
await syncWorkspaceContext(ctx, agent)
|
||
return workspaceContextOf(agent)
|
||
}
|
||
|
||
function baselineEvents(agent: Agent): SessionEvent[] {
|
||
return agent.session.events.filter(event =>
|
||
event.type === 'user/message'
|
||
&& event.data.source.kind === 'agent-instructions'
|
||
&& event.data.source.baseline === true)
|
||
}
|
||
|
||
async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise<number | undefined> {
|
||
await syncedWorkspaceContext(ctx, agent)
|
||
let lastSeq: number | undefined
|
||
for (const claimed of agent.inbox.claim('next-step', 1)) {
|
||
if (claimed.source.kind !== 'agent-instructions') continue
|
||
const event = agent.session.append('user/message', claimed, { surfaceOp: 'append' })
|
||
ctx.emit('session/event', agent.session, event)
|
||
lastSeq = event.seq
|
||
}
|
||
return lastSeq
|
||
}
|
||
|
||
const composedPrefixes = new WeakMap<object, Message[]>()
|
||
|
||
async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Message[]> {
|
||
const signal = AbortSignal.timeout(1000)
|
||
await agentEvents(ctx, agent).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: [], turn: 1, step: 1, signal },
|
||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||
)
|
||
const claimed = agent.inbox.claim('next-step', 1)
|
||
const decision = await agentEvents(ctx, agent).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: claimed, turn: 1, step: 2, signal },
|
||
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
|
||
)
|
||
const entered = decision.kind === 'enter' ? decision.messages : []
|
||
for (const message of entered) {
|
||
const event = agent.session.append('user/message', message, { surfaceOp: 'append' })
|
||
ctx.emit('session/event', agent.session, event)
|
||
}
|
||
const prefix = agent.session.deriveMessages()
|
||
composedPrefixes.set(agent, prefix)
|
||
return prefix
|
||
}
|
||
|
||
function derivedText(agent: Agent): string {
|
||
return blocksText(composedPrefixes.get(agent)?.[0]?.content)
|
||
}
|
||
|
||
function expectNoDerivedMessages(agent: Agent): void {
|
||
expect(agent.session.deriveMessages()).toEqual([])
|
||
expect(composedPrefixes.get(agent) ?? []).toEqual([])
|
||
}
|
||
|
||
describe('workspace context instruction discovery', () => {
|
||
it('treats ENOTDIR while probing a host candidate as confirmed absence', async () => {
|
||
const root = await tempRepo()
|
||
const homeFile = join(root, 'not-a-directory')
|
||
try {
|
||
await writeFile(homeFile, 'file')
|
||
|
||
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: homeFile })
|
||
|
||
expect(files).toEqual([])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('loads user-global first, then every root-to-cwd candidate in precedence order', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
const cwd = join(root, 'packages/app')
|
||
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')
|
||
await write(join(root, 'packages/CLAUDE.md'), 'package claude')
|
||
await write(join(cwd, 'AGENTS.md'), 'app agents')
|
||
|
||
const files = await discoverBaselineInstructionFiles({ cwd, dshHome: home })
|
||
|
||
expect(files.map(file => file.displayPath)).toEqual([
|
||
'$DSH_HOME/AGENTS.md',
|
||
'AGENTS.md',
|
||
'CLAUDE.md',
|
||
join('packages', 'CLAUDE.md'),
|
||
join('packages', 'app', 'AGENTS.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 })
|
||
}
|
||
})
|
||
|
||
it('loads a same-directory local overlay in addition to the base file by default', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
const cwd = join(root, 'pkg')
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'root base')
|
||
await write(join(root, 'AGENTS.local.md'), 'root local')
|
||
await write(join(cwd, 'CLAUDE.md'), 'pkg base')
|
||
await write(join(cwd, 'CLAUDE.local.md'), 'pkg local')
|
||
|
||
const files = await discoverBaselineInstructionFiles({ cwd, dshHome: home })
|
||
|
||
expect(files.map(file => file.displayPath)).toEqual([
|
||
'AGENTS.md',
|
||
'AGENTS.local.md',
|
||
join('pkg', 'CLAUDE.md'),
|
||
join('pkg', 'CLAUDE.local.md'),
|
||
])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('loads no local overlay when localInstructionFileCandidates is empty', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'base rule')
|
||
await write(join(root, 'AGENTS.local.md'), 'local rule')
|
||
|
||
const files = await discoverBaselineInstructionFiles({
|
||
cwd: root,
|
||
dshHome: home,
|
||
localInstructionFileCandidates: [],
|
||
})
|
||
|
||
expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md'])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('treats a .git file as a project root marker and does not search above it', async () => {
|
||
const outer = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
const root = join(outer, 'worktree')
|
||
const cwd = join(root, 'src')
|
||
await write(join(outer, 'AGENTS.md'), 'outer must not load')
|
||
await write(join(root, '.git'), 'gitdir: ../.git/worktrees/worktree')
|
||
await write(join(root, 'AGENTS.md'), 'root')
|
||
await mkdir(cwd, { recursive: true })
|
||
|
||
const files = await discoverBaselineInstructionFiles({ cwd, dshHome: home })
|
||
|
||
expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md'])
|
||
} finally {
|
||
await rm(outer, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('re-reads content after a same-version, same-size rewrite', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
const cwd = join(root, 'pkg')
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await mkdir(cwd, { recursive: true })
|
||
|
||
expect(await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })).toBeUndefined()
|
||
|
||
const leaf = join(cwd, 'AGENTS.md')
|
||
await write(leaf, 'first')
|
||
const first = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
|
||
expect(first?.text).toContain('first')
|
||
const again = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
|
||
expect(again?.text).toContain('first')
|
||
|
||
const before = await stat(leaf)
|
||
await writeFile(leaf, 'other')
|
||
await utimes(leaf, before.atime, before.mtime)
|
||
const second = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
|
||
expect(second?.text).toContain('other')
|
||
expect(second?.text).not.toContain('first')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
// POSIX-only fixture: chmod 0 cannot make a file unreadable to its owner on Windows.
|
||
it.skipIf(process.platform === 'win32')('skips a file that becomes unreadable after discovery without failing the request', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
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 write(leaf, 'secret-ish rule')
|
||
await chmod(leaf, 0)
|
||
|
||
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
|
||
|
||
expect(loaded).toBeUndefined()
|
||
await chmod(leaf, 0o600)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
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, '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.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 })
|
||
await rm(outside, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
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, '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)
|
||
|
||
expect(derivedText(agent)).toContain('shared provider instruction body')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
await rm(outside, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('disables baseline loading when the byte budget is zero', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||
|
||
await expect(loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 0 })).resolves.toBeUndefined()
|
||
await expect(loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, maxSourceBytes: 0 })).resolves.toBeUndefined()
|
||
await expect(loadBaselineInstructions({
|
||
cwd: root, dshHome: home, maxBytes: 65536, maxSourceBytes: Infinity,
|
||
})).resolves.toBeUndefined()
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('honors configured instruction candidates that exclude CLAUDE.md', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'CLAUDE.md'), 'claude only')
|
||
|
||
const files = await discoverBaselineInstructionFiles({
|
||
cwd: root,
|
||
dshHome: home,
|
||
instructionFileCandidates: ['AGENTS.md'],
|
||
})
|
||
|
||
expect(files).toEqual([])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
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 {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'native rule')
|
||
await write(join(root, 'CLAUDE.local.md'), 'local claude rule')
|
||
await write(join(root, 'CLAUDE.md'), 'claude rule')
|
||
|
||
const files = await discoverBaselineInstructionFiles({
|
||
cwd: root,
|
||
dshHome: home,
|
||
instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.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 })
|
||
}
|
||
})
|
||
|
||
it('ignores configured instruction candidates that are not same-directory file names', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'native rule')
|
||
await write(join(root, '.claude/CLAUDE.md'), 'nested claude rule')
|
||
|
||
const files = await discoverBaselineInstructionFiles({
|
||
cwd: root,
|
||
dshHome: home,
|
||
instructionFileCandidates: ['', '.', '..', '.claude/CLAUDE.md', 'nested\\CLAUDE.md', 'AGENTS.md'],
|
||
})
|
||
|
||
expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md'])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
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
|
||
// the home dirs point at an empty dir so the default ~/.dsh holds no global
|
||
// scope. Windows homedir() reads USERPROFILE (not HOME), so both must be
|
||
// stubbed or a real ~/.dsh/AGENTS.md would otherwise leak in.
|
||
vi.stubEnv('DSH_HOME', '')
|
||
vi.stubEnv('HOME', emptyHome)
|
||
if (process.platform === 'win32') vi.stubEnv('USERPROFILE', emptyHome)
|
||
try {
|
||
const cwd = join(root, 'child')
|
||
await mkdir(cwd, { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'parent without marker')
|
||
await write(join(cwd, 'AGENTS.md'), 'cwd without marker')
|
||
|
||
const files = await discoverBaselineInstructionFiles({ cwd })
|
||
|
||
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 })
|
||
}
|
||
})
|
||
|
||
it('honors DSH_HOME when dshHome is not configured explicitly', async () => {
|
||
const root = await tempRepo()
|
||
const envHome = await tempRepo()
|
||
try {
|
||
await write(join(envHome, 'AGENTS.md'), 'env global rule')
|
||
vi.stubEnv('DSH_HOME', envHome)
|
||
|
||
const files = await discoverBaselineInstructionFiles({ cwd: root })
|
||
|
||
expect(files).toEqual([{ absolutePath: join(envHome, 'AGENTS.md'), displayPath: '$DSH_HOME/AGENTS.md' }])
|
||
} finally {
|
||
vi.unstubAllEnvs()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(envHome, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('labels the default DSH home as ~/.dsh when HOME points at the configured default', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await write(join(home, '.dsh/AGENTS.md'), 'global default rule')
|
||
|
||
// A set DSH_HOME would override the homedir default and relabel the home.
|
||
vi.stubEnv('DSH_HOME', '')
|
||
vi.resetModules()
|
||
vi.doMock('node:os', () => ({ homedir: () => home }))
|
||
const isolated = await import('@deepseek-ai/dsh-agent-instructions')
|
||
const files = await isolated.discoverBaselineInstructionFiles({ cwd: root })
|
||
|
||
expect(files.map(file => file.displayPath)).toEqual(['~/.dsh/AGENTS.md'])
|
||
} finally {
|
||
vi.unstubAllEnvs()
|
||
vi.doUnmock('node:os')
|
||
vi.resetModules()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('expands a configured ~/.dsh home to the operating-system home directory', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await write(join(home, '.dsh/AGENTS.md'), 'global tilde rule')
|
||
|
||
vi.resetModules()
|
||
vi.doMock('node:os', () => ({ homedir: () => home }))
|
||
const isolated = await import('@deepseek-ai/dsh-agent-instructions')
|
||
const files = await isolated.discoverBaselineInstructionFiles({ cwd: root, dshHome: '~/.dsh' })
|
||
|
||
expect(files).toEqual([{ absolutePath: join(home, '.dsh/AGENTS.md'), displayPath: '~/.dsh/AGENTS.md' }])
|
||
} finally {
|
||
vi.doUnmock('node:os')
|
||
vi.resetModules()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('deduplicates user-global instructions when dshHome points at the project root', async () => {
|
||
const root = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'same file')
|
||
|
||
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: root })
|
||
|
||
expect(files).toEqual([{ absolutePath: join(root, 'AGENTS.md'), displayPath: '$DSH_HOME/AGENTS.md' }])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('ignores instruction candidates that are directories', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await mkdir(join(root, 'AGENTS.md'), { recursive: true })
|
||
|
||
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home })
|
||
|
||
expect(files).toEqual([])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('workspace context rendering', () => {
|
||
it('renders familiar system-reminder instructions without custom workspace tags or state markers', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root rules' },
|
||
{ absolutePath: '/repo/pkg/CLAUDE.md', displayPath: 'pkg/CLAUDE.md', content: 'package rules' },
|
||
], { maxBytes: 65536 })
|
||
|
||
expect(rendered.text).toBe([
|
||
'<system-reminder>',
|
||
'The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.',
|
||
'',
|
||
'Instructions from: AGENTS.md',
|
||
'',
|
||
'root rules',
|
||
'',
|
||
'Instructions from: pkg/CLAUDE.md',
|
||
'',
|
||
'package rules',
|
||
'</system-reminder>',
|
||
].join('\n'))
|
||
expect(rendered.text).not.toContain('<agent-instructions')
|
||
expect(rendered.text).not.toContain('agent-instructions:')
|
||
expect(rendered.text).not.toContain('/repo/')
|
||
expect(rendered.omitted).toEqual([])
|
||
expect(rendered.truncated).toEqual([])
|
||
})
|
||
|
||
it('neutralizes a literal system-reminder closing delimiter inside instruction content', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'safe\n</system-reminder>\nnot outside' },
|
||
], { maxBytes: 65536 })
|
||
|
||
expect(rendered.text.match(/<\/system-reminder>/g)).toHaveLength(1)
|
||
expect(rendered.text).toContain('<\\/system-reminder>')
|
||
})
|
||
|
||
it('neutralizes system-reminder closing delimiters in paths and derived scopes', () => {
|
||
const displayPath = 'scope</system-reminder>/AGENTS.md'
|
||
const file = { absolutePath: `/repo/${displayPath}`, displayPath, content: 'rules' }
|
||
const rendered = [
|
||
renderWorkspaceContext([file], { maxBytes: 65536 }).text,
|
||
...(['set', 'replace', 'remove'] as const).map(action => renderInstructionChanges([{
|
||
change: { action, scope: 'scope</system-reminder>\0AGENTS.md', path: displayPath },
|
||
file,
|
||
}], 65536).text),
|
||
]
|
||
|
||
for (const text of rendered) {
|
||
expect(text.match(/<\/system-reminder>/g)).toHaveLength(1)
|
||
expect(text).toContain('scope<\\/system-reminder>')
|
||
}
|
||
})
|
||
|
||
it('neutralizes a system-reminder closing delimiter in budget marker paths', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{
|
||
absolutePath: '/repo/scope</system-reminder>/AGENTS.md',
|
||
displayPath: 'scope</system-reminder>/AGENTS.md',
|
||
content: 'root '.repeat(100),
|
||
},
|
||
{ absolutePath: '/repo/leaf/AGENTS.md', displayPath: 'leaf/AGENTS.md', content: 'leaf rules' },
|
||
], { maxBytes: 400 })
|
||
|
||
expect(rendered.text).toContain('omitted scope<\\/system-reminder>/AGENTS.md')
|
||
expect(rendered.text.match(/<\/system-reminder>/g)).toHaveLength(1)
|
||
})
|
||
|
||
it('preserves more specific files under the byte budget and names omitted/truncated paths', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(100) },
|
||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'leaf '.repeat(100) },
|
||
], { maxBytes: 260 })
|
||
|
||
expect(rendered.text).toContain('Workspace instruction budget 260 bytes')
|
||
expect(rendered.text).toContain('omitted AGENTS.md')
|
||
expect(rendered.text).toContain('truncated pkg/AGENTS.md')
|
||
expect(rendered.text).toContain('Instructions from: pkg/AGENTS.md')
|
||
expect(rendered.text).not.toContain('Instructions from: AGENTS.md\n\nroot')
|
||
expect(rendered.omitted.map(item => item.displayPath)).toEqual(['AGENTS.md'])
|
||
expect(rendered.truncated.map(item => item.displayPath)).toEqual(['pkg/AGENTS.md'])
|
||
})
|
||
|
||
it('keeps the rendered block within the byte budget when files are both omitted and truncated', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(100) },
|
||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'leaf '.repeat(100) },
|
||
], { maxBytes: 260 })
|
||
|
||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(260)
|
||
expect(rendered.text).not.toContain(':;')
|
||
expect(rendered.omitted.map(item => item.displayPath)).toEqual(['AGENTS.md'])
|
||
expect(rendered.truncated.map(item => item.displayPath)).toEqual(['pkg/AGENTS.md'])
|
||
})
|
||
|
||
it('drops a parent file while keeping a specific child file intact when the child fits', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(200) },
|
||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'leaf rule' },
|
||
], { maxBytes: 700 })
|
||
|
||
expect(rendered.text).toContain('omitted AGENTS.md')
|
||
expect(rendered.text).toContain('Instructions from: pkg/AGENTS.md\n\nleaf rule')
|
||
expect(rendered.text).not.toContain('root root')
|
||
expect(rendered.omitted.map(item => item.displayPath)).toEqual(['AGENTS.md'])
|
||
expect(rendered.truncated).toEqual([])
|
||
})
|
||
|
||
it('keeps the longest most-specific suffix that fits under the byte budget', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(200) },
|
||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'package rule' },
|
||
{ absolutePath: '/repo/pkg/app/AGENTS.md', displayPath: 'pkg/app/AGENTS.md', content: 'app rule' },
|
||
], { maxBytes: 760 })
|
||
|
||
expect(rendered.text).toContain('omitted AGENTS.md')
|
||
expect(rendered.text).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule')
|
||
expect(rendered.text).toContain('Instructions from: pkg/app/AGENTS.md\n\napp rule')
|
||
expect(rendered.text).not.toContain('root root')
|
||
expect(rendered.omitted.map(item => item.displayPath)).toEqual(['AGENTS.md'])
|
||
expect(rendered.truncated).toEqual([])
|
||
})
|
||
|
||
it('truncates a single oversized file to the largest content slice that fits', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'x'.repeat(1000) },
|
||
], { maxBytes: 700 })
|
||
|
||
expect(rendered.text).toContain('truncated AGENTS.md')
|
||
expect(rendered.text).toContain('Instructions from: AGENTS.md')
|
||
expect(rendered.truncated).toHaveLength(1)
|
||
expect(rendered.truncated[0]?.originalBytes).toBe(1000)
|
||
expect(rendered.truncated[0]!.includedBytes).toBeGreaterThan(0)
|
||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(700)
|
||
})
|
||
|
||
it('omits all text when the render budget is disabled', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root rules' },
|
||
], { maxBytes: 0 })
|
||
|
||
expect(rendered).toEqual({
|
||
text: '',
|
||
omitted: [{ absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root rules' }],
|
||
truncated: [],
|
||
})
|
||
})
|
||
|
||
it('falls back to a compact truncation notice when even the empty heading cannot fit', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||
], { maxBytes: 100 })
|
||
|
||
expect(rendered.text).toBe('Workspace instruction budget 100 bytes: truncated pkg/AGENTS.md from 1000 to 0 bytes')
|
||
expect(rendered.truncated).toEqual([{ displayPath: 'pkg/AGENTS.md', originalBytes: 1000, includedBytes: 0 }])
|
||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(100)
|
||
})
|
||
|
||
it('keeps the empty instruction heading when it fits beside the compact notice', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||
], { maxBytes: 120 })
|
||
|
||
expect(rendered.text).toBe([
|
||
'Workspace instruction budget 120 bytes: truncated pkg/AGENTS.md from 1000 to 0 bytes',
|
||
'',
|
||
'Instructions from: pkg/AGENTS.md',
|
||
'',
|
||
'',
|
||
].join('\n'))
|
||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(120)
|
||
})
|
||
|
||
it('represents a genuinely empty instruction when its compact heading fits', () => {
|
||
const file = { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: '' }
|
||
const rendered = renderWorkspaceInstructionSet([file], { maxBytes: 117 })
|
||
|
||
expect(rendered.rendered.text).toContain('truncated pkg/AGENTS.md from 0 to 0 bytes')
|
||
expect(rendered.rendered.text).toContain('Instructions from: pkg/AGENTS.md')
|
||
expect(rendered.included).toEqual([file])
|
||
})
|
||
|
||
it('represents a genuinely empty instruction through the framed compact-intro path', () => {
|
||
const file = {
|
||
absolutePath: '/repo/pkg/AGENTS.md',
|
||
displayPath: 'pkg/AGENTS.md',
|
||
content: '',
|
||
}
|
||
|
||
const rendered = renderWorkspaceInstructionSet([file], { maxBytes: 300 })
|
||
|
||
expect(rendered.rendered.text).toContain('<system-reminder>')
|
||
expect(rendered.rendered.text).toContain('Workspace instructions were omitted or truncated')
|
||
expect(rendered.rendered.text).toContain('Instructions from: pkg/AGENTS.md')
|
||
expect(rendered.rendered.truncated).toEqual([
|
||
{ displayPath: 'pkg/AGENTS.md', originalBytes: 0, includedBytes: 0 },
|
||
])
|
||
expect(rendered.included).toEqual([file])
|
||
})
|
||
|
||
it('truncates the compact notice itself when the render budget is smaller than the notice', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||
], { maxBytes: 20 })
|
||
|
||
expect(rendered.text).toBe('Workspace instructio')
|
||
expect(rendered.truncated).toEqual([{ displayPath: 'pkg/AGENTS.md', originalBytes: 1000, includedBytes: 0 }])
|
||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(20)
|
||
})
|
||
|
||
it('does not commit a change when only the generic compact notice survives', () => {
|
||
const change = {
|
||
action: 'set' as const,
|
||
scope: sk('pkg', 'AGENTS.md'),
|
||
path: 'pkg/AGENTS.md',
|
||
digest: 'digest',
|
||
}
|
||
const rendered = renderInstructionChanges([{
|
||
change,
|
||
file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||
}], 20)
|
||
|
||
expect(rendered.text).toBe('Workspace instructio')
|
||
expect(rendered.changes).toEqual([])
|
||
})
|
||
|
||
it('commits a change when its file-specific semantic section survives truncation', () => {
|
||
const change = {
|
||
action: 'replace' as const,
|
||
scope: sk('pkg', 'AGENTS.md'),
|
||
path: 'pkg/AGENTS.md',
|
||
digest: 'digest',
|
||
}
|
||
const rendered = renderInstructionChanges([{
|
||
change,
|
||
file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||
}], 400)
|
||
|
||
expect(rendered.text).toContain('Updated instructions from: pkg/AGENTS.md')
|
||
expect(rendered.changes).toEqual([change])
|
||
})
|
||
|
||
// Each prose-derived budget is the smallest current value that retains the named heading plus a zero-byte marker.
|
||
it.each([
|
||
{ action: 'set' as const, maxBytes: 327, heading: 'Additional instructions from:' },
|
||
{ action: 'replace' as const, maxBytes: 256, heading: 'Updated instructions from:' },
|
||
])('does not commit a $action change when its heading survives with zero content bytes', ({ action, maxBytes, heading }) => {
|
||
const change = {
|
||
action,
|
||
scope: sk('pkg', 'AGENTS.md'),
|
||
path: 'pkg/AGENTS.md',
|
||
digest: 'digest',
|
||
}
|
||
const rendered = renderInstructionChanges([{
|
||
change,
|
||
file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
||
}], maxBytes)
|
||
|
||
expect(rendered.text).toContain(heading)
|
||
expect(rendered.text).toContain('from 1000 to 0 bytes')
|
||
expect(rendered.changes).toEqual([])
|
||
})
|
||
|
||
it('does not commit a multibyte change when the budget cuts its first code point', () => {
|
||
const change = {
|
||
action: 'set' as const,
|
||
scope: sk('pkg', 'AGENTS.md'),
|
||
path: 'pkg/AGENTS.md',
|
||
digest: 'digest',
|
||
}
|
||
const rendered = renderInstructionChanges([{
|
||
change,
|
||
file: { absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: '😀'.repeat(100) },
|
||
}], 366)
|
||
|
||
expect(rendered.text).not.toContain('<27>')
|
||
expect(rendered.text).not.toContain('😀')
|
||
expect(rendered.changes).toEqual([])
|
||
})
|
||
|
||
it('keeps compact truncation notices within budget when a multibyte display path is cut', () => {
|
||
const rendered = renderWorkspaceContext([
|
||
{ absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) },
|
||
], { maxBytes: 51 })
|
||
|
||
expect(Buffer.byteLength(rendered.text, 'utf8')).toBeLessThanOrEqual(51)
|
||
})
|
||
})
|
||
|
||
describe('workspace context request injection', () => {
|
||
it('requires an explicit maxBytes configuration', async () => {
|
||
const ctx = new Context()
|
||
|
||
await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/)
|
||
})
|
||
|
||
it('mounts without requiring a filesystem provider', async () => {
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
}
|
||
})
|
||
|
||
it('does not declare fs as a static inject dependency', () => {
|
||
expect('inject' in workspaceContext).toBe(false)
|
||
})
|
||
|
||
it('does not inject baseline context when no filesystem provider is present', async () => {
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||
const agent = stubAgent('/virtual/repo')
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
expectNoDerivedMessages(agent)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
}
|
||
})
|
||
|
||
it('contributes baseline instructions through durable injected history', 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 mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
expect(baselineEvents(agent)).toHaveLength(1)
|
||
expect(baselineEvents(agent)[0]).toMatchObject({
|
||
type: 'user/message',
|
||
data: {
|
||
role: 'user',
|
||
source: {
|
||
kind: 'agent-instructions',
|
||
form: 'instructions',
|
||
baseline: true,
|
||
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||
},
|
||
},
|
||
})
|
||
const baseline = baselineEvents(agent)[0]
|
||
expect(baseline?.type === 'user/message' && Array.isArray(baseline.data.content)).toBe(true)
|
||
expect(composedPrefixes.get(agent)).toHaveLength(1)
|
||
expect(derivedText(agent)).toContain('<system-reminder>')
|
||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
|
||
expect(derivedText(agent)).toContain('repo rule')
|
||
expect(derivedText(agent)).not.toContain('<context source=')
|
||
expect(derivedText(agent)).not.toContain('<agent-instructions')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('queues and later commits one durable baseline contribution', 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 mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
const first = await composeBaselinePrefix(ctx, agent)
|
||
const second = await composeBaselinePrefix(ctx, agent)
|
||
|
||
expect(second).toEqual(first)
|
||
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
|
||
expect(derivedText(agent)).toContain('repo rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('retains one visible baseline across repeated session resumes', 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 mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const original = stubAgent(root)
|
||
await composeBaselinePrefix(ctx, original)
|
||
|
||
const firstResume = stubAgent(root, [...original.session.events])
|
||
await composeBaselinePrefix(ctx, firstResume)
|
||
const secondResume = stubAgent(root, [...firstResume.session.events])
|
||
await composeBaselinePrefix(ctx, secondResume)
|
||
|
||
expect(baselineEvents(firstResume)).toHaveLength(1)
|
||
expect(baselineEvents(secondResume)).toHaveLength(1)
|
||
expect(secondResume.session.events.filter(event => event.type === 'user/message'
|
||
&& event.data.source.kind === 'agent-instructions')).toHaveLength(1)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('preserves a visible baseline when its source is unavailable during resume', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
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: 'repo rule' })
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const original = stubAgent(root)
|
||
await composeBaselinePrefix(ctx, original)
|
||
|
||
fs.throwOnStat.add(join(root, 'AGENTS.md'))
|
||
const resumed = stubAgent(root, [...original.session.events])
|
||
await composeBaselinePrefix(ctx, resumed)
|
||
|
||
expect(baselineEvents(resumed)).toHaveLength(1)
|
||
expect(resumed.session.events.filter(event => event.type === 'user/message'
|
||
&& event.data.source.kind === 'agent-instructions')).toHaveLength(1)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not promote an unchanged budget-omitted baseline file during resume', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
const cwd = join(root, 'pkg')
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
|
||
await write(join(cwd, 'AGENTS.md'), 'package rule')
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
|
||
const original = stubAgent(cwd)
|
||
await composeBaselinePrefix(ctx, original)
|
||
|
||
const firstResume = stubAgent(cwd, [...original.session.events])
|
||
await composeBaselinePrefix(ctx, firstResume)
|
||
const secondResume = stubAgent(cwd, [...firstResume.session.events])
|
||
await composeBaselinePrefix(ctx, secondResume)
|
||
|
||
expect(baselineEvents(secondResume)).toHaveLength(1)
|
||
expect(secondResume.session.events.filter(event => event.type === 'user/message'
|
||
&& event.data.source.kind === 'agent-instructions')).toHaveLength(1)
|
||
expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).toContain('omitted AGENTS.md')
|
||
expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).not.toContain('root root')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('removes a previously visible baseline file that leaves the retained budget set', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
const cwd = join(root, 'pkg')
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await mkdir(cwd, { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
|
||
const original = stubAgent(cwd)
|
||
await composeBaselinePrefix(ctx, original)
|
||
|
||
await write(join(cwd, 'AGENTS.md'), 'package rule')
|
||
const resumed = stubAgent(cwd, [...original.session.events])
|
||
await composeBaselinePrefix(ctx, resumed)
|
||
|
||
expect(baselineEvents(resumed)).toHaveLength(1)
|
||
const update = resumed.session.events.findLast(event => event.type === 'user/message'
|
||
&& event.data.source.kind === 'agent-instructions'
|
||
&& event.data.source.baseline !== true)
|
||
expect(update?.type === 'user/message' && update.data.source.kind === 'agent-instructions'
|
||
? update.data.source.changes
|
||
: undefined).toMatchObject([
|
||
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
|
||
{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') },
|
||
])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('recomposes the baseline when candidate precedence changes between resumes', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const originalCtx = new Context()
|
||
const resumedCtx = new Context()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'agents rule')
|
||
await write(join(root, 'CLAUDE.md'), 'claude rule')
|
||
await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 })
|
||
const original = stubAgent(root)
|
||
await composeBaselinePrefix(originalCtx, original)
|
||
|
||
await mountWorkspaceContext(resumedCtx, {
|
||
dshHome: home,
|
||
maxBytes: 65536,
|
||
instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'],
|
||
})
|
||
const resumed = stubAgent(root, [...original.session.events])
|
||
await composeBaselinePrefix(resumedCtx, resumed)
|
||
|
||
const baselines = baselineEvents(resumed)
|
||
expect(baselines).toHaveLength(2)
|
||
const replacement = baselines.at(-1)
|
||
const replacementText = replacement?.type === 'user/message'
|
||
? blocksText(replacement.data.content)
|
||
: ''
|
||
expect(replacementText).toContain('replaces all earlier workspace instruction baselines')
|
||
expect(replacementText.indexOf('Instructions from: CLAUDE.md'))
|
||
.toBeLessThan(replacementText.indexOf('Instructions from: AGENTS.md'))
|
||
const baselineIdentities = baselines.flatMap(event => event.type === 'user/message'
|
||
&& event.data.source.kind === 'agent-instructions'
|
||
&& typeof event.data.source.baselineIdentity === 'string'
|
||
? [event.data.source.baselineIdentity]
|
||
: [])
|
||
expect(new Set(baselineIdentities).size).toBe(2)
|
||
|
||
const repeated = stubAgent(root, [...resumed.session.events])
|
||
await composeBaselinePrefix(resumedCtx, repeated)
|
||
expect(baselineEvents(repeated)).toHaveLength(2)
|
||
} finally {
|
||
await originalCtx.fiber.dispose()
|
||
await resumedCtx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('tombstones candidates removed across successive baseline configurations', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const agentsCtx = new Context()
|
||
const claudeCtx = new Context()
|
||
const restoredCtx = new Context()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'agents rule')
|
||
await write(join(root, 'CLAUDE.md'), 'claude rule')
|
||
await mountWorkspaceContext(agentsCtx, {
|
||
dshHome: home,
|
||
maxBytes: 65536,
|
||
instructionFileCandidates: ['AGENTS.md'],
|
||
})
|
||
const original = stubAgent(root)
|
||
await composeBaselinePrefix(agentsCtx, original)
|
||
|
||
await mountWorkspaceContext(claudeCtx, {
|
||
dshHome: home,
|
||
maxBytes: 65536,
|
||
instructionFileCandidates: ['CLAUDE.md'],
|
||
})
|
||
const claudeResume = stubAgent(root, [...original.session.events])
|
||
await composeBaselinePrefix(claudeCtx, claudeResume)
|
||
const claudeBaseline = baselineEvents(claudeResume).at(-1)
|
||
expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'agent-instructions'
|
||
? claudeBaseline.data.source.changes
|
||
: undefined).toMatchObject([
|
||
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
|
||
{ action: 'set', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' },
|
||
])
|
||
|
||
await mountWorkspaceContext(restoredCtx, {
|
||
dshHome: home,
|
||
maxBytes: 65536,
|
||
instructionFileCandidates: ['AGENTS.md'],
|
||
})
|
||
const restored = stubAgent(root, [...claudeResume.session.events])
|
||
await composeBaselinePrefix(restoredCtx, restored)
|
||
const restoredBaseline = baselineEvents(restored).at(-1)
|
||
expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'agent-instructions'
|
||
? restoredBaseline.data.source.changes
|
||
: undefined).toMatchObject([
|
||
{ action: 'remove', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' },
|
||
{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
|
||
])
|
||
} finally {
|
||
await agentsCtx.fiber.dispose()
|
||
await claudeCtx.fiber.dispose()
|
||
await restoredCtx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('supersedes an incompatible visible baseline when no current candidate exists', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const originalCtx = new Context()
|
||
const resumedCtx = new Context()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'agents rule')
|
||
await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 })
|
||
const original = stubAgent(root)
|
||
await composeBaselinePrefix(originalCtx, original)
|
||
|
||
await mountWorkspaceContext(resumedCtx, {
|
||
dshHome: home,
|
||
maxBytes: 65536,
|
||
instructionFileCandidates: ['POLICY.md'],
|
||
})
|
||
const resumed = stubAgent(root, [...original.session.events])
|
||
await composeBaselinePrefix(resumedCtx, resumed)
|
||
|
||
const baselines = baselineEvents(resumed)
|
||
expect(baselines).toHaveLength(2)
|
||
const replacement = baselines.at(-1)
|
||
expect(replacement?.type === 'user/message' ? blocksText(replacement.data.content) : '')
|
||
.toContain('No workspace instructions are currently active.')
|
||
expect(replacement?.type === 'user/message' && replacement.data.source.kind === 'agent-instructions'
|
||
? replacement.data.source.changes
|
||
: undefined).toMatchObject([
|
||
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
|
||
])
|
||
|
||
const repeated = stubAgent(root, [...resumed.session.events])
|
||
await composeBaselinePrefix(resumedCtx, repeated)
|
||
expect(baselineEvents(repeated)).toHaveLength(2)
|
||
} finally {
|
||
await originalCtx.fiber.dispose()
|
||
await resumedCtx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('reuses an inserted but unadmitted baseline after session recovery and plugin reload', 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()
|
||
const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const original = stubAgent(root)
|
||
await agentEvents(ctx, original).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||
)
|
||
const inserted = original.inbox.nextStep[0]
|
||
expect(inserted?.source).toMatchObject({ kind: 'agent-instructions', baseline: true })
|
||
|
||
await fiber.dispose()
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const resumed = stubAgent(root, [...original.session.events])
|
||
agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
|
||
const claimed = resumed.inbox.claim('next-step', 1)
|
||
const decision = await agentEvents(ctx, resumed).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
|
||
)
|
||
if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected')
|
||
for (const message of decision.messages) {
|
||
const event = resumed.session.append('user/message', message, { surfaceOp: 'append' })
|
||
ctx.emit('session/event', resumed.session, event)
|
||
}
|
||
|
||
expect(decision.messages.map(message => message.id)).toEqual([inserted?.id])
|
||
expect(resumed.inbox.nextStep).toEqual([])
|
||
expect(resumed.session.events.filter(event => event.type === 'agent/inbox/spliced'
|
||
&& event.data.inserted.some(message => message.source.kind === 'agent-instructions'
|
||
&& message.source.baseline === true))).toHaveLength(1)
|
||
expect(baselineEvents(resumed)).toHaveLength(1)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('replaces a recovered unadmitted baseline when its source changed offline', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'old repo rule')
|
||
const ctx = new Context()
|
||
const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const original = stubAgent(root)
|
||
await agentEvents(ctx, original).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||
)
|
||
const stale = original.inbox.nextStep[0]
|
||
expect(blocksText(stale?.content)).toContain('old repo rule')
|
||
|
||
await write(join(root, 'AGENTS.md'), 'new repo rule')
|
||
await fiber.dispose()
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const resumed = stubAgent(root, [...original.session.events])
|
||
agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
|
||
const staleClaim = resumed.inbox.claim('next-step', 1)
|
||
const staleDecision = await agentEvents(ctx, resumed).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||
() => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }),
|
||
)
|
||
|
||
if (staleDecision.kind !== 'enter') throw new Error('recovered baseline was rejected')
|
||
expect(staleDecision.messages).toHaveLength(2)
|
||
expect(staleDecision.messages[0]).toBe(staleClaim[0])
|
||
const replacement = staleDecision.messages[1]
|
||
expect(replacement?.id).not.toBe(stale?.id)
|
||
expect(blocksText(replacement?.content)).toContain('new repo rule')
|
||
expect(blocksText(replacement?.content)).not.toContain('old repo rule')
|
||
expect(resumed.inbox.nextStep).toHaveLength(0)
|
||
|
||
for (const message of staleDecision.messages) {
|
||
const event = resumed.session.append('user/message', message, { surfaceOp: 'append' })
|
||
ctx.emit('session/event', resumed.session, event)
|
||
}
|
||
expect(baselineEvents(resumed)).toHaveLength(1)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it.each([
|
||
{ label: 'baseline loading is disabled', maxBytes: 0, provideFs: true },
|
||
{ label: 'the filesystem provider is unavailable', maxBytes: 65536, provideFs: false },
|
||
])('does not requeue recovered workspace contexts when $label', async ({ maxBytes, provideFs }) => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const originalCtx = new Context()
|
||
const resumedCtx = new Context()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||
await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 })
|
||
const original = stubAgent(root)
|
||
await agentEvents(originalCtx, original).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||
)
|
||
const stale = original.inbox.nextStep[0]
|
||
expect(stale?.source).toMatchObject({ kind: 'agent-instructions', baseline: true })
|
||
|
||
await originalCtx.fiber.dispose()
|
||
if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' })
|
||
await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes })
|
||
const resumed = stubAgent(root, [...original.session.events])
|
||
agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' })
|
||
const claimed = resumed.inbox.claim('next-step', 1)
|
||
const decision = await agentEvents(resumedCtx, resumed).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
|
||
)
|
||
|
||
expect(claimed.map(message => message.id)).toEqual([stale?.id])
|
||
expect(decision).toEqual({ kind: 'enter', messages: claimed })
|
||
expect(resumed.inbox.nextStep).toEqual([])
|
||
} finally {
|
||
await originalCtx.fiber.dispose()
|
||
await resumedCtx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('queues and records a removal for stale visible nested context', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
agent.session.append('user/message', createUserMessage({
|
||
content: [{ type: 'text', text: 'stale nested instructions' }],
|
||
source: {
|
||
kind: 'agent-instructions',
|
||
form: 'instructions',
|
||
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }],
|
||
},
|
||
}), {
|
||
surfaceOp: 'append',
|
||
})
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
const removal = agent.session.events.find(event => event.type === 'user/message'
|
||
&& event.data.source.kind === 'agent-instructions'
|
||
&& event.data.source.changes.some(change => change.action === 'remove'))
|
||
expect(removal?.type === 'user/message' ? removal.data.source : undefined).toMatchObject({
|
||
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||
})
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('combines startup reconciliation and baseline into one durable inbox context', 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 mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
agent.session.append('user/message', createUserMessage({
|
||
content: [{ type: 'text', text: 'stale nested instructions' }],
|
||
source: {
|
||
kind: 'agent-instructions',
|
||
form: 'instructions',
|
||
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }],
|
||
},
|
||
}), {
|
||
surfaceOp: 'append',
|
||
})
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
const workspaceEvents = agent.session.events.filter(event => event.type === 'user/message'
|
||
&& event.data.source.kind === 'agent-instructions')
|
||
expect(workspaceEvents).toHaveLength(2)
|
||
expect(workspaceEvents.some(event => event.type === 'user/message'
|
||
&& event.data.source.kind === 'agent-instructions'
|
||
&& event.data.source.changes.some(change => change.action === 'remove'))).toBe(true)
|
||
expect(baselineEvents(agent)).toHaveLength(1)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
expect(agent.session.events.filter(event => event.type === 'user/message'
|
||
&& event.data.source.kind === 'agent-instructions')).toHaveLength(2)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('enters the baseline right after the claimed prompt in the first pre-step without queuing another step', 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 mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
const prompt = createUserMessage({
|
||
content: [{ type: 'text', text: 'current prompt' }],
|
||
source: { kind: 'user' },
|
||
})
|
||
const downstream = { kind: 'enter' as const, messages: [prompt] }
|
||
|
||
const decision = await agentEvents(ctx, agent).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||
() => Promise.resolve(downstream),
|
||
)
|
||
|
||
expect(decision).toMatchObject({ kind: 'enter' })
|
||
if (decision.kind !== 'enter') throw new Error('workspace baseline was rejected')
|
||
expect(decision.messages).toHaveLength(2)
|
||
expect(decision.messages[0]).toBe(prompt)
|
||
expect(decision.messages[1]?.source).toMatchObject({ kind: 'agent-instructions', baseline: true })
|
||
expect(blocksText(decision.messages[1]?.content)).toContain('Instructions from: AGENTS.md')
|
||
expect(agent.inbox.nextStep).toHaveLength(0)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('reports a changed user-global instruction through the visible-scope reconcile path', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||
await write(join(home, 'AGENTS.md'), 'global rule')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
await write(join(home, 'AGENTS.md'), 'updated global rule')
|
||
await syncWorkspaceContext(ctx, agent)
|
||
|
||
const pending = await workspaceContextOf(agent)
|
||
expect(pending?.source).toMatchObject({
|
||
kind: 'agent-instructions',
|
||
changes: [{ action: 'replace', scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE) }],
|
||
})
|
||
expect(blocksText(pending?.content)).toContain('updated global rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('queues the desired workspace context when the current step is rejected', 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 mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
const downstream = { kind: 'reject' as const }
|
||
|
||
const decision = await agentEvents(ctx, agent).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
|
||
() => Promise.resolve(downstream),
|
||
)
|
||
|
||
expect(decision).toBe(downstream)
|
||
expect(agent.inbox.nextStep).toHaveLength(1)
|
||
expect(blocksText(agent.inbox.nextStep[0]?.content)).toContain('repo rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('retains a visible baseline after a plugin remount', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||
await write(join(root, 'file.txt'), 'hello')
|
||
const ctx = new Context()
|
||
const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
// Hot remount over the live session: the durable baseline remains
|
||
// visible, so the fresh mount does not append a duplicate.
|
||
await fiber.dispose()
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
expect(baselineEvents(agent)).toHaveLength(1)
|
||
|
||
await write(join(root, 'AGENTS.md'), 'updated repo rule')
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-remount'),
|
||
name: 'read',
|
||
arguments: { file_path: 'file.txt' },
|
||
agent,
|
||
})
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||
})
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('restores a compacted baseline on a hot plugin remount', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||
const ctx = new Context()
|
||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||
const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
await composeBaselinePrefix(ctx, agent)
|
||
const baseline = baselineEvents(agent)[0]
|
||
expect(baseline).toBeDefined()
|
||
|
||
agent.session.append('user/message', createUserMessage({
|
||
content: [{ type: 'text', text: 'compacted summary' }],
|
||
source: { kind: 'plugin', plugin: 'compact' },
|
||
}), {
|
||
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
|
||
sourceEventSeqs: [baseline!.seq],
|
||
})
|
||
|
||
await fiber.dispose()
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
expect(baselineEvents(agent)).toHaveLength(2)
|
||
expect(blocksText(agent.session.deriveMessages().at(-1)?.content)).toContain('repo rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('folds a compacted baseline into the next entering pre-step before another filesystem touch', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'first post-compaction request rule')
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
await composeBaselinePrefix(ctx, agent)
|
||
const baseline = baselineEvents(agent)[0]
|
||
expect(baseline).toBeDefined()
|
||
|
||
agent.session.append('user/message', createUserMessage({
|
||
content: [{ type: 'text', text: 'compacted summary' }],
|
||
source: { kind: 'plugin', plugin: 'compact' },
|
||
}), {
|
||
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
|
||
sourceEventSeqs: [baseline!.seq],
|
||
})
|
||
const prompt = createUserMessage({
|
||
content: [{ type: 'text', text: 'continue after compaction' }],
|
||
source: { kind: 'user' },
|
||
})
|
||
|
||
const decision = await agentEvents(ctx, agent).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: [prompt], turn: 2, step: 1, signal: AbortSignal.timeout(1000) },
|
||
() => Promise.resolve({ kind: 'enter' as const, messages: [prompt] }),
|
||
)
|
||
|
||
if (decision.kind !== 'enter') throw new Error('post-compaction request was rejected')
|
||
expect(decision.messages).toHaveLength(2)
|
||
expect(decision.messages[0]).toBe(prompt)
|
||
expect(decision.messages[1]?.source).toMatchObject({ kind: 'agent-instructions', baseline: true })
|
||
expect(blocksText(decision.messages[1]?.content)).toContain('first post-compaction request rule')
|
||
expect(agent.inbox.nextStep).toEqual([])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('appends a replacement transition when a resumed session edited its baseline offline', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'old root rule')
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const original = stubAgent(root)
|
||
await composeBaselinePrefix(ctx, original)
|
||
|
||
// The first resumed pre-step retains the compatible visible baseline and
|
||
// appends only the offline file transition needed to reach current state.
|
||
await write(join(root, 'AGENTS.md'), 'new root rule after offline edit')
|
||
const resumed = stubAgent(root, [...original.session.events])
|
||
|
||
// Resume announces its lifecycle start before the first step.
|
||
agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
|
||
await composeBaselinePrefix(ctx, resumed)
|
||
|
||
const baselines = baselineEvents(resumed)
|
||
expect(baselines).toHaveLength(1)
|
||
const latest = resumed.session.events.findLast(event =>
|
||
event.type === 'user/message' && event.data.source.kind === 'agent-instructions')
|
||
expect(latest?.type === 'user/message' ? latest.data.source : undefined).toMatchObject({
|
||
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||
})
|
||
expect(latest?.type === 'user/message' && blocksText(latest.data.content))
|
||
.toContain('new root rule after offline edit')
|
||
const original0 = baselines[0]
|
||
expect(original0?.type === 'user/message' && blocksText(original0.data.content))
|
||
.toContain('old root rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('tracks only baseline files that were actually included under the byte budget', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
const cwd = join(root, 'pkg')
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
|
||
await write(join(cwd, 'AGENTS.md'), 'package rule')
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
|
||
const agent = stubAgent(cwd)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
expect(derivedText(agent)).toContain('omitted AGENTS.md')
|
||
expect(derivedText(agent)).toContain(`Instructions from: ${join('pkg', 'AGENTS.md')}\n\npackage rule`)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('keeps an independent pre-step contribution after the queued workspace context', 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 mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
ctx.on('agent/pre-step', async (_payload, next) => {
|
||
const decision = await next()
|
||
if (decision.kind === 'reject') return decision
|
||
return {
|
||
...decision,
|
||
messages: [
|
||
...decision.messages,
|
||
createUserMessage({ content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }], source: { kind: 'plugin', plugin: 'test-skills' } }),
|
||
],
|
||
}
|
||
})
|
||
|
||
const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
|
||
|
||
expect(prefix).toHaveLength(2)
|
||
expect(blocksText(prefix[0]?.content)).toContain('Instructions from: AGENTS.md')
|
||
expect(blocksText(prefix[1]?.content)).toBe('<system-reminder>Available skills</system-reminder>')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('queues a replacement when a frozen baseline file changes before a later fs tool call', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'old root rule')
|
||
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)
|
||
await write(join(root, 'AGENTS.md'), 'new root rule with more detail')
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
|
||
})
|
||
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||
})
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('Updated instructions from: AGENTS.md')
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('new root rule with more detail')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('queues a removal when a frozen baseline file is deleted before a later fs tool call', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'root rule')
|
||
await write(join(root, 'file.txt'), 'hello')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
await rm(join(root, 'AGENTS.md'))
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
|
||
})
|
||
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||
})
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('Instructions removed: AGENTS.md')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('deduplicates one AGENTS.md that is both user-global and the project-root candidate', async () => {
|
||
const root = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'shared root and global rule')
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { dshHome: root, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
expect(derivedText(agent).match(/shared root and global rule/g)).toHaveLength(1)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
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.each([10, 120])('does not expose state markers when baseline content is omitted at %i bytes', async (maxBytes) => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'x'.repeat(1000))
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes })
|
||
const agent = stubAgent(root)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
const contexts = agent.session.events.filter(event =>
|
||
event.type === 'user/message' && event.data.source.kind !== 'user',
|
||
)
|
||
expect(contexts).toHaveLength(1)
|
||
const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined
|
||
expect(source?.kind === 'agent-instructions' ? source.changes : undefined).toEqual([])
|
||
if (maxBytes === 120) {
|
||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
|
||
expect(derivedText(agent)).toContain('from 1000 to 0 bytes')
|
||
} else {
|
||
expect(derivedText(agent)).not.toContain('Instructions from: AGENTS.md')
|
||
}
|
||
expect(derivedText(agent)).not.toContain('agent-instructions:')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
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(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
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 })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('loads provider-visible instruction files that do not exist on the host filesystem', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
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: 'provider-only rule' })
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
expect(derivedText(agent)).toContain('provider-only rule')
|
||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('keeps the direct provider API usable without an operation signal', async () => {
|
||
const root = resolve('/virtual/no-signal-repo')
|
||
const home = resolve('/virtual/no-signal-home')
|
||
const ctx = new Context()
|
||
try {
|
||
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: 'optional capability signal' })
|
||
|
||
const rendered = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }, fs)
|
||
|
||
expect(rendered?.text).toContain('optional capability signal')
|
||
expect(fs.signals).toEqual([])
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
}
|
||
})
|
||
|
||
it('rejects a provider-sized instruction file before reading content', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
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: 'far too large' })
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 })
|
||
|
||
const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
|
||
|
||
expect(prefix).toEqual([])
|
||
expect(fs.readTargets).toEqual([])
|
||
expect(fs.readTextTargets).toEqual([])
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('bounds streamed instruction content when provider size is unavailable', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
const instructionPath = join(root, 'AGENTS.md')
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(instructionPath, { type: 'file', content: 'far too large' })
|
||
fs.omitSizes.add(instructionPath)
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 })
|
||
|
||
const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
|
||
|
||
expect(prefix).toEqual([])
|
||
expect(fs.readTargets).toEqual([instructionPath, instructionPath])
|
||
expect(fs.readTextTargets).toEqual([])
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('aborts an in-flight baseline stream with the prompt signal', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(BlockingReadFileSystem)
|
||
const fs = ctx.fs as BlockingReadFileSystem
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'blocked' })
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const controller = new AbortController()
|
||
const reason = new Error('cancel prefix')
|
||
const pending = agentEvents(ctx, stubAgent(root)).waterfall(
|
||
'agent/pre-step',
|
||
{ messages: [], turn: 1, step: 1, signal: controller.signal },
|
||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||
)
|
||
|
||
await fs.started.promise
|
||
controller.abort(reason)
|
||
|
||
await expect(pending).rejects.toBe(reason)
|
||
expect(fs.signals).toContain(controller.signal)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(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(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
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 })
|
||
}
|
||
})
|
||
|
||
it('skips provider-visible instruction candidates 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(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()
|
||
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(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('skips provider-visible instruction candidates 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(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('skips a candidate whose provider probe fails while still loading its available sibling', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
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'))
|
||
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)
|
||
|
||
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 })
|
||
}
|
||
})
|
||
|
||
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(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
expect(derivedText(agent)).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()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(repoA, '.git'), { recursive: true })
|
||
await mkdir(join(repoB, '.git'), { recursive: true })
|
||
await write(join(repoA, 'AGENTS.md'), 'repo A only')
|
||
await write(join(repoB, 'AGENTS.md'), 'repo B only')
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agentA = stubAgent(repoA)
|
||
const agentB = stubAgent(repoB)
|
||
|
||
await composeBaselinePrefix(ctx, agentA)
|
||
await composeBaselinePrefix(ctx, agentB)
|
||
|
||
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 })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('uses schema defaults on the plugin path so ancestor discovery still finds .git roots', async () => {
|
||
const root = await tempRepo()
|
||
try {
|
||
const cwd = join(root, 'child')
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await mkdir(cwd, { recursive: true })
|
||
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(LocalFileSystem, { cwd: '/' })
|
||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||
const agent = stubAgent(cwd)
|
||
|
||
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`)
|
||
await ctx.fiber.dispose()
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('renders a default local overlay alongside the base file in the baseline prefix', async () => {
|
||
const root = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'base rule')
|
||
await write(join(root, 'AGENTS.local.md'), 'local rule')
|
||
const ctx = new Context()
|
||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
await composeBaselinePrefix(ctx, agent)
|
||
|
||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nbase rule')
|
||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.local.md\n\nlocal rule')
|
||
await ctx.fiber.dispose()
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('cleans up its pre-step listener when the plugin fiber is disposed', 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()
|
||
const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
await fiber.dispose()
|
||
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('does not inject anything when maxBytes is zero', 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 mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 0 })
|
||
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('does not inject an empty agent-instructions message when maxBytes is negative', 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 mountWorkspaceContext(ctx, { dshHome: home, maxBytes: -1 })
|
||
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('leaves the request unchanged when no instruction files are present', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { 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('labels a custom dshHome as DSH_HOME instead of pretending it is ~/.dsh', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await write(join(home, 'AGENTS.md'), 'global custom rule')
|
||
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home })
|
||
|
||
expect(files.map(file => file.displayPath)).toEqual(['$DSH_HOME/AGENTS.md'])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not repeat a candidate metadata probe during one discovery and read pass', 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 observedStats = new Map<string, number>()
|
||
vi.resetModules()
|
||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||
return {
|
||
...actual,
|
||
stat: async (path: string) => {
|
||
observedStats.set(path, (observedStats.get(path) ?? 0) + 1)
|
||
return actual.stat(path)
|
||
},
|
||
}
|
||
})
|
||
const isolated = await import('@deepseek-ai/dsh-agent-instructions')
|
||
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
|
||
observedStats.clear()
|
||
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
|
||
|
||
expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1)
|
||
} finally {
|
||
vi.doUnmock('node:fs/promises')
|
||
vi.resetModules()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
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'), 'claude host sibling rule')
|
||
vi.resetModules()
|
||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||
return {
|
||
...actual,
|
||
stat: async (path: string) => {
|
||
if (path === join(root, 'AGENTS.md')) {
|
||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' })
|
||
}
|
||
return actual.stat(path)
|
||
},
|
||
}
|
||
})
|
||
const isolated = await import('@deepseek-ai/dsh-agent-instructions')
|
||
|
||
const rendered = await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
|
||
|
||
expect(rendered?.text).toContain('claude host sibling rule')
|
||
} finally {
|
||
vi.doUnmock('node:fs/promises')
|
||
vi.resetModules()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('dynamic nested workspace context injection', () => {
|
||
it('projects a successful file result even when a later sibling aborts the step', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), 'nested rule survives an aborted tool batch')
|
||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||
const adapter = new MockAdapter([
|
||
[
|
||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('read-before-abort'), name: 'read', arguments: '{"file_path":"pkg/deep/file.txt"}' } },
|
||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('abort-after-read'), name: 'abort_step', arguments: '{}' } },
|
||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||
] satisfies StreamChunk[],
|
||
toolCallResponse('read-after-abort', 'read', { file_path: join('pkg', 'deep', 'file.txt') }),
|
||
textResponse('done'),
|
||
])
|
||
await ctx.plugin(LlmRuntime)
|
||
await ctx.plugin(SessionStore)
|
||
await ctx.plugin(SystemPrompt)
|
||
await ctx.plugin(ToolRuntime)
|
||
await ctx.plugin(AgentRegistry)
|
||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||
await ctx.plugin(ToolFs)
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
await ctx.plugin(AgentLoop, { agents: [] })
|
||
ctx.llm.registerAdapter(['mock'], adapter)
|
||
const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root })
|
||
ctx.tools.register(defineContentToolFixture({
|
||
name: 'abort_step',
|
||
description: 'Abort the current test step.',
|
||
parameters: {},
|
||
async execute() {
|
||
agent.cancel({ kind: 'user' })
|
||
return [{ type: 'text', text: 'aborted' }]
|
||
},
|
||
}))
|
||
|
||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'read and abort' }], source: { kind: 'user' } }))
|
||
await agent.whenIdle()
|
||
expect(agent.session.events.filter(event =>
|
||
event.type === 'user/message' && event.data.source.kind !== 'user',
|
||
)).toHaveLength(0)
|
||
|
||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'retry the read' }], source: { kind: 'user' } }))
|
||
await agent.whenIdle()
|
||
|
||
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||
expect(contexts).toHaveLength(1)
|
||
expect(adapter.requests).toHaveLength(3)
|
||
expect(adapter.requests.at(-1)?.messages.map(blocks => blocksText(blocks.content)).join('\n'))
|
||
.toContain('nested rule survives an aborted tool batch')
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('builds persisted digest state without inventing a provider version', () => {
|
||
const state = baselineInstructionState([{
|
||
absolutePath: '/repo/AGENTS.md',
|
||
displayPath: 'AGENTS.md',
|
||
content: 'root rule',
|
||
}])
|
||
|
||
const change = state.changes.get(sk('.', 'AGENTS.md'))
|
||
expect(change).toMatchObject({
|
||
action: 'set',
|
||
path: 'AGENTS.md',
|
||
})
|
||
expect(change?.digest).toMatch(/^[a-f0-9]{40}$/)
|
||
expect(state.versions).toEqual(new Map())
|
||
})
|
||
|
||
it('creates and releases version-cache state only for non-empty updates', () => {
|
||
const agent = stubAgent('/repo')
|
||
const cache: InstructionVersionCache = new WeakMap()
|
||
const change = { action: 'set' as const, scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md', digest: 'digest' }
|
||
applyInstructionVersionUpdates(agent.session, [], cache)
|
||
expect(cache.get(agent.session)).toBeUndefined()
|
||
|
||
applyInstructionVersionUpdates(agent.session, [{
|
||
change,
|
||
state: { path: 'AGENTS.md', version: FsVersion('v1'), digest: 'digest', trimmedDigest: 'trimmed' },
|
||
}], cache)
|
||
expect(cache.get(agent.session)?.has(change.scope)).toBe(true)
|
||
applyInstructionVersionUpdates(agent.session, [{ change: { ...change, action: 'remove' } }], cache)
|
||
expect(cache.get(agent.session)).toBeUndefined()
|
||
})
|
||
|
||
it('does not refresh dynamic instructions after the tool signal is aborted', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested' })
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const controller = new AbortController()
|
||
const reason = new Error('cancel dynamic reconciliation')
|
||
controller.abort(reason)
|
||
const exec = stubToolExecution({
|
||
callId: CallId('cancelled-dynamic-read'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'file.txt') },
|
||
agent: stubAgent(root),
|
||
signal: controller.signal,
|
||
})
|
||
|
||
ctx.emit('tools/result', exec, {
|
||
content: [{ type: 'text', text: 'ok' }],
|
||
isError: false,
|
||
value: null,
|
||
})
|
||
|
||
await Promise.resolve()
|
||
expect(fs.signals).toEqual([])
|
||
expect(exec.agent?.inbox.nextStep).toEqual([])
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('attaches newly discovered nested instructions after a successful file read touches a descendant path', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'baseline root rule')
|
||
await write(join(root, 'pkg/AGENTS.md'), 'nested package 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'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
expect(result.isError).toBe(false)
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'agent-instructions' })
|
||
const queuedSource = ((await syncedWorkspaceContext(ctx, agent))).source
|
||
expect(queuedSource).toMatchObject({ kind: 'agent-instructions', form: 'instructions' })
|
||
expect(queuedSource.kind === 'agent-instructions' && queuedSource.changes.some(change =>
|
||
change.action === 'set'
|
||
&& change.scope === sk('pkg', 'AGENTS.md')
|
||
&& change.path === join('pkg', 'AGENTS.md'))).toBe(true)
|
||
const source = ((await syncedWorkspaceContext(ctx, agent))).source
|
||
const firstChange = source?.kind === 'agent-instructions'
|
||
? source.changes.find(change => change.scope === sk('pkg', 'AGENTS.md'))
|
||
: undefined
|
||
const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange)
|
||
? firstChange.digest
|
||
: undefined
|
||
expect(changeDigest).toMatch(/^[a-f0-9]{40}$/)
|
||
const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
|
||
expect(text).toContain('nested package rule')
|
||
expect(text).not.toContain('<agent-instructions')
|
||
expect(text).toContain('baseline root rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('finishes a committed file-result projection after the tool signal ends', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
const controller = new AbortController()
|
||
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: controller.signal,
|
||
callId: CallId('read-before-signal-end'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'file.txt') },
|
||
agent,
|
||
}), { content: [{ type: 'text', text: 'ok' }], isError: false, value: null })
|
||
controller.abort(new Error('tool execution ended'))
|
||
|
||
expect(blocksText((await workspaceContextOf(agent)).content)).toContain('nested package rule')
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('loads every configured instruction candidate present in a nested 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'), 'native package rule')
|
||
await write(join(root, 'pkg/CLAUDE.local.md'), 'local package rule')
|
||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, {
|
||
dshHome: home,
|
||
maxBytes: 65536,
|
||
instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'],
|
||
})
|
||
const agent = stubAgent(root)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-configured-nested-candidate'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`)
|
||
expect(text).toContain('local package rule')
|
||
expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
|
||
expect(text).toContain('native package rule')
|
||
expect(text.indexOf(join('pkg', 'CLAUDE.local.md'))).toBeLessThan(text.indexOf(join('pkg', 'AGENTS.md')))
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('attaches a nested base file and its local overlay together by default', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'baseline root rule')
|
||
await write(join(root, 'pkg/AGENTS.md'), 'nested base rule')
|
||
await write(join(root, 'pkg/AGENTS.local.md'), 'nested local 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)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-nested-overlay'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
const source = ((await syncedWorkspaceContext(ctx, agent))).source
|
||
const changes = source?.kind === 'agent-instructions'
|
||
? source.changes
|
||
: []
|
||
expect(changes).toEqual(expect.arrayContaining([
|
||
expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.md') }),
|
||
expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.local.md') }),
|
||
]))
|
||
const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
|
||
expect(text).toContain('nested base rule')
|
||
expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.local.md')}`)
|
||
expect(text).toContain('nested local rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not attach a nested local overlay when the overlay is disabled', 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 base rule')
|
||
await write(join(root, 'pkg/AGENTS.local.md'), 'nested local rule')
|
||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, {
|
||
dshHome: home,
|
||
maxBytes: 65536,
|
||
localInstructionFileCandidates: [],
|
||
})
|
||
const agent = stubAgent(root)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-nested-overlay-disabled'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
|
||
expect(text).not.toContain(join('pkg', 'AGENTS.local.md'))
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not attach nested instructions again for the same session once a path has been loaded', 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 package 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 first = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-nested-1'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
await appendAdditionalContexts(ctx, agent)
|
||
const second = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-nested-2'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
expect(first.additionalContexts).toBeUndefined()
|
||
expect(second.additionalContexts).toBeUndefined()
|
||
expect(agent.inbox.nextStep).toEqual([])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('skips instruction content reads while provider version and effective state are unchanged', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(SystemPrompt)
|
||
await ctx.plugin(ToolRuntime)
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
const instructionPath = join(root, 'pkg/AGENTS.md')
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(instructionPath, { type: 'file', content: 'nested package rule' })
|
||
fs.omitSizes.add(instructionPath)
|
||
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
|
||
await ctx.plugin(ToolFs)
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
const first = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await appendAdditionalContexts(ctx, agent)
|
||
const second = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
|
||
expect(first.additionalContexts).toBeUndefined()
|
||
expect(second.additionalContexts).toBeUndefined()
|
||
expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(1)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('re-reads a changed provider version, then refreshes metadata when SHA-1 is unchanged', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(SystemPrompt)
|
||
await ctx.plugin(ToolRuntime)
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
const instructionPath = join(root, 'pkg/AGENTS.md')
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-1') })
|
||
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
|
||
await ctx.plugin(ToolFs)
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await appendAdditionalContexts(ctx, agent)
|
||
fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') })
|
||
const afterVersionChange = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await syncWorkspaceContext(ctx, agent)
|
||
const afterRefresh = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
|
||
expect(afterVersionChange.additionalContexts).toBeUndefined()
|
||
expect(afterRefresh.additionalContexts).toBeUndefined()
|
||
await vi.waitFor(() => {
|
||
expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2)
|
||
})
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('isolates instruction version caches between sessions that touch the same scope', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(SystemPrompt)
|
||
await ctx.plugin(ToolRuntime)
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
const instructionPath = join(root, 'pkg/AGENTS.md')
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(instructionPath, { type: 'file', content: 'shared path, separate sessions' })
|
||
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
|
||
await ctx.plugin(ToolFs)
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
|
||
const firstAgent = stubAgent(root)
|
||
const secondAgent = stubAgent(root)
|
||
const first = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: firstAgent,
|
||
})
|
||
const second = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: secondAgent,
|
||
})
|
||
|
||
expect(first.additionalContexts).toBeUndefined()
|
||
expect(second.additionalContexts).toBeUndefined()
|
||
expect(((await syncedWorkspaceContext(ctx, firstAgent))).source.kind).toBe('agent-instructions')
|
||
expect(((await syncedWorkspaceContext(ctx, secondAgent))).source.kind).toBe('agent-instructions')
|
||
expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('replaces previously loaded instructions when the same file content changes', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), 'old 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)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await appendAdditionalContexts(ctx, agent)
|
||
await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail')
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
kind: 'agent-instructions',
|
||
form: 'instructions',
|
||
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||
})
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toBe([
|
||
'<system-reminder>',
|
||
`Updated instructions from: ${join('pkg', 'AGENTS.md')}`,
|
||
'',
|
||
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
|
||
'',
|
||
'new package rule with more detail',
|
||
'</system-reminder>',
|
||
].join('\n'))
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
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'), 'sibling 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)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-both-siblings'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
expect(firstText).toContain('native package rule')
|
||
expect(firstText).toContain('sibling package rule')
|
||
await appendAdditionalContexts(ctx, agent)
|
||
await rm(join(root, 'pkg/AGENTS.md'))
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
|
||
// Removing one candidate only removes its own scope; the sibling scope is untouched.
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||
})
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`)
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).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)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent,
|
||
})
|
||
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||
})
|
||
const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
expect(text.match(/nested rule/g)).toHaveLength(1)
|
||
expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
|
||
expect(text).not.toContain(join('pkg', 'CLAUDE.md'))
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it.each(['visible', 'claimed'] as const)(
|
||
'keeps unavailable active candidate groups unchanged with cold and warm caches when authority is $s',
|
||
async (authority) => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(join(root, 'pkg/CLAUDE.md'), { type: 'file', content: 'nested rule' })
|
||
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
|
||
const agent = stubAgent(root)
|
||
const agentsScope = sk('pkg', 'AGENTS.md')
|
||
const loaded = baselineInstructionState([{
|
||
absolutePath: join(root, 'pkg/AGENTS.md'),
|
||
displayPath: join('pkg', 'AGENTS.md'),
|
||
content: 'nested rule',
|
||
version: FsVersion('loaded-agents'),
|
||
}])
|
||
const previous = loaded.changes.get(agentsScope)
|
||
if (previous === undefined) throw new Error('missing AGENTS.md baseline state')
|
||
const authoritative = createUserMessage({
|
||
content: [{ type: 'text', text: 'nested rule' }],
|
||
source: { kind: 'agent-instructions', form: 'instructions', changes: [previous] },
|
||
})
|
||
if (authority === 'visible') {
|
||
agent.session.append('user/message', authoritative, { surfaceOp: 'append' })
|
||
}
|
||
const authorityMessages = authority === 'claimed' ? [authoritative] : []
|
||
|
||
for (const instructionFileCandidates of [
|
||
['AGENTS.md', 'CLAUDE.md'],
|
||
['CLAUDE.md', 'AGENTS.md'],
|
||
]) {
|
||
const resolved = resolveConfig({
|
||
dshHome: home,
|
||
maxBytes: 65536,
|
||
instructionFileCandidates,
|
||
localInstructionFileCandidates: [],
|
||
})
|
||
const coldCache: InstructionVersionCache = new WeakMap()
|
||
const warmCache: InstructionVersionCache = new WeakMap()
|
||
warmCache.set(agent.session, new Map(loaded.versions))
|
||
const options = {
|
||
authorityMessages,
|
||
scopeMessages: [createUserMessage({
|
||
content: [{ type: 'text', text: 'pending baseline duplicate' }],
|
||
source: {
|
||
kind: 'agent-instructions',
|
||
form: 'instructions',
|
||
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||
},
|
||
})],
|
||
touchedPaths: [],
|
||
includeBaselineScopes: false,
|
||
signal: testToolSignal,
|
||
}
|
||
|
||
const cold = await reconcileInstructionContext(agent, resolved, coldCache, fs, options)
|
||
const warm = await reconcileInstructionContext(agent, resolved, warmCache, fs, options)
|
||
|
||
expect(cold).toEqual(warm)
|
||
expect(cold).toBeUndefined()
|
||
}
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
},
|
||
)
|
||
|
||
it('skips visible baseline scopes when baseline scopes are excluded from reconciliation', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
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: 'repo rule' })
|
||
const agent = stubAgent(root)
|
||
const rootScope = sk('.', 'AGENTS.md')
|
||
const loaded = baselineInstructionState([{
|
||
absolutePath: join(root, 'AGENTS.md'),
|
||
displayPath: 'AGENTS.md',
|
||
content: 'repo rule',
|
||
version: FsVersion('loaded-agents'),
|
||
}])
|
||
const previous = loaded.changes.get(rootScope)
|
||
if (previous === undefined) throw new Error('missing AGENTS.md baseline state')
|
||
const authoritative = createUserMessage({
|
||
content: [{ type: 'text', text: 'repo rule' }],
|
||
source: { kind: 'agent-instructions', form: 'instructions', changes: [previous] },
|
||
})
|
||
agent.session.append('user/message', authoritative, { surfaceOp: 'append' })
|
||
const resolved = resolveConfig({ dshHome: home, maxBytes: 65536, localInstructionFileCandidates: [] })
|
||
const cache: InstructionVersionCache = new WeakMap()
|
||
cache.set(agent.session, new Map(loaded.versions))
|
||
const options = {
|
||
authorityMessages: [],
|
||
scopeMessages: [],
|
||
touchedPaths: [],
|
||
includeBaselineScopes: false,
|
||
signal: testToolSignal,
|
||
}
|
||
|
||
const result = await reconcileInstructionContext(agent, resolved, cache, fs, options)
|
||
|
||
expect(result).toBeUndefined()
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('ignores an unavailable scope whose visible state is already removed', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
|
||
const agent = stubAgent(root)
|
||
agent.session.append('user/message', createUserMessage({
|
||
content: [{ type: 'text', text: 'removed nested instructions' }],
|
||
source: {
|
||
kind: 'agent-instructions',
|
||
form: 'instructions',
|
||
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||
},
|
||
}), { surfaceOp: 'append' })
|
||
const resolved = resolveConfig({
|
||
dshHome: home,
|
||
maxBytes: 65536,
|
||
instructionFileCandidates: ['AGENTS.md'],
|
||
localInstructionFileCandidates: [],
|
||
})
|
||
|
||
const result = await reconcileInstructionContext(agent, resolved, new WeakMap(), fs, {
|
||
authorityMessages: [],
|
||
scopeMessages: [],
|
||
touchedPaths: [],
|
||
includeBaselineScopes: false,
|
||
signal: testToolSignal,
|
||
})
|
||
|
||
expect(result).toBeUndefined()
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('loads one transition when user-global and project scopes resolve to the same file', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const ctx = new Context()
|
||
try {
|
||
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: 'shared rule' })
|
||
const agent = stubAgent(root)
|
||
const resolved = resolveConfig({
|
||
dshHome: root,
|
||
maxBytes: 65536,
|
||
instructionFileCandidates: ['AGENTS.md'],
|
||
localInstructionFileCandidates: [],
|
||
})
|
||
|
||
const result = await reconcileInstructionContext(agent, resolved, new WeakMap(), fs, {
|
||
authorityMessages: [],
|
||
scopeMessages: [],
|
||
touchedPaths: [],
|
||
includeBaselineScopes: true,
|
||
signal: testToolSignal,
|
||
})
|
||
|
||
expect(result?.context.source).toMatchObject({
|
||
changes: [{ action: 'set', scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE) }],
|
||
})
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { 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)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
expect(firstText).toContain('canonical nested rule')
|
||
expect(firstText).toContain('divergent nested rule')
|
||
await appendAdditionalContexts(ctx, agent)
|
||
await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule')
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
|
||
const convergence = await syncedWorkspaceContext(ctx, agent)
|
||
expect(convergence.source).toMatchObject({
|
||
changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }],
|
||
})
|
||
expect(blocksText(convergence.content)).toContain(`Instructions removed: ${join('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)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await appendAdditionalContexts(ctx, agent)
|
||
// Only the earlier candidate changes; the sibling stays byte-identical but now duplicates it.
|
||
await write(join(root, 'pkg/AGENTS.md'), 'secondary nested rule')
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
changes: [
|
||
{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') },
|
||
{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') },
|
||
],
|
||
})
|
||
const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
expect(text).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`)
|
||
expect(text).toContain(`Updated instructions from: ${join('pkg', 'AGENTS.md')}`)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('removes previously loaded instructions when no candidate remains 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'), '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)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await appendAdditionalContexts(ctx, agent)
|
||
await rm(join(root, 'pkg/AGENTS.md'))
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
kind: 'agent-instructions',
|
||
form: 'instructions',
|
||
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||
})
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toBe([
|
||
'<system-reminder>',
|
||
`Instructions removed: ${join('pkg', 'AGENTS.md')}`,
|
||
'',
|
||
'The previously loaded instructions from this file no longer apply.',
|
||
'</system-reminder>',
|
||
].join('\n'))
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
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)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
await appendAdditionalContexts(ctx, agent)
|
||
expect(firstText).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'))
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||
})
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain(`Instructions removed: ${join('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()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), 'first 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)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await appendAdditionalContexts(ctx, agent)
|
||
await rm(join(root, 'pkg/AGENTS.md'))
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await appendAdditionalContexts(ctx, agent)
|
||
await write(join(root, 'pkg/AGENTS.md'), 'restored package rule')
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||
})
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('restored package rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not report removal when a previously loaded scope is temporarily unavailable', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(SystemPrompt)
|
||
await ctx.plugin(ToolRuntime)
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'provider package rule' })
|
||
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
|
||
await ctx.plugin(ToolFs)
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
const first = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await appendAdditionalContexts(ctx, agent)
|
||
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
|
||
const duringFailure = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
|
||
expect(first.additionalContexts).toBeUndefined()
|
||
expect(duringFailure.additionalContexts).toBeUndefined()
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('derives loaded nested instructions from resumed session history instead of duplicating them', 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 package 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 first = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-resume'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
await appendAdditionalContexts(ctx, agent)
|
||
const resumed = stubAgent(root, [...agent.session.events])
|
||
|
||
const afterResume = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-resume'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent: resumed,
|
||
})
|
||
|
||
expect(first.additionalContexts).toBeUndefined()
|
||
expect(afterResume.additionalContexts).toBeUndefined()
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('appends an update during resumed prefix composition when visible nested instructions changed offline', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), 'old nested rule')
|
||
await write(join(root, 'pkg/file.txt'), 'hello')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const original = stubAgent(root)
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: original,
|
||
})
|
||
await appendAdditionalContexts(ctx, original)
|
||
await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume')
|
||
const resumed = stubAgent(root, [...original.session.events])
|
||
|
||
await composeBaselinePrefix(ctx, resumed)
|
||
|
||
const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||
expect(update?.type === 'user/message' && update.data.source).toMatchObject({
|
||
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||
})
|
||
expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('re-arms a nested instruction after compaction removes its context message from the surface', 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 package 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 first = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-before-compact'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
const contextSeq = (await appendAdditionalContexts(ctx, agent))!
|
||
const visibleBeforeCompact = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-while-visible'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
agent.session.append('user/message', createUserMessage({
|
||
content: [{ type: 'text', text: 'compacted summary' }],
|
||
source: { kind: 'plugin', plugin: 'compact' },
|
||
}), {
|
||
surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq },
|
||
sourceEventSeqs: [contextSeq],
|
||
})
|
||
|
||
const afterCompact = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-compact'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
expect(first.additionalContexts).toBeUndefined()
|
||
expect(visibleBeforeCompact.additionalContexts).toBeUndefined()
|
||
expect(afterCompact.additionalContexts).toBeUndefined()
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('re-arms an unchanged baseline after compaction removes it from the surface', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'root rule')
|
||
await write(join(root, 'file.txt'), 'hello')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
await composeBaselinePrefix(ctx, agent)
|
||
const baseline = baselineEvents(agent)[0]
|
||
expect(baseline).toBeDefined()
|
||
|
||
const whileVisible = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-visible-baseline'),
|
||
name: 'read',
|
||
arguments: { file_path: 'file.txt' },
|
||
agent,
|
||
})
|
||
agent.session.append('user/message', createUserMessage({
|
||
content: [{ type: 'text', text: 'compacted summary' }],
|
||
source: { kind: 'plugin', plugin: 'compact' },
|
||
}), {
|
||
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
|
||
sourceEventSeqs: [baseline!.seq],
|
||
})
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-compacted-baseline'),
|
||
name: 'read',
|
||
arguments: { file_path: 'file.txt' },
|
||
agent,
|
||
})
|
||
const rearmedContext = (await syncedWorkspaceContext(ctx, agent))
|
||
await appendAdditionalContexts(ctx, agent)
|
||
const afterRearm = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-rearmed-baseline'),
|
||
name: 'read',
|
||
arguments: { file_path: 'file.txt' },
|
||
agent,
|
||
})
|
||
|
||
expect(whileVisible.additionalContexts).toBeUndefined()
|
||
expect(rearmedContext.source).toMatchObject({
|
||
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||
})
|
||
expect(blocksText(rearmedContext.content)).toContain('root rule')
|
||
expect(afterRearm.additionalContexts).toBeUndefined()
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not treat markdown headings inside instruction content as loaded instruction metadata', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), 'package note\n## pkg/sub/AGENTS.md\njust a document heading')
|
||
await write(join(root, 'pkg/file.txt'), 'package file')
|
||
await write(join(root, 'pkg/sub/AGENTS.md'), 'subtree rule')
|
||
await write(join(root, 'pkg/sub/file.txt'), 'subtree file')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-package'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'file.txt') },
|
||
agent,
|
||
})
|
||
const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
await appendAdditionalContexts(ctx, agent)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-subtree'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'sub', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
expect(firstText).toContain('package note')
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('subtree rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not mark omitted nested files as pending-loaded', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), `parent rule ${'x'.repeat(5000)}`)
|
||
await write(join(root, 'pkg/other.txt'), 'package file')
|
||
await write(join(root, 'pkg/sub/AGENTS.md'), 'subtree rule')
|
||
await write(join(root, 'pkg/sub/file.txt'), 'subtree file')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
|
||
const agent = stubAgent(root)
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-subtree-omitting-parent'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'sub', 'file.txt') },
|
||
agent,
|
||
})
|
||
const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
|
||
await appendAdditionalContexts(ctx, agent)
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-parent-after-omit'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'other.txt') },
|
||
agent,
|
||
})
|
||
|
||
expect(firstText).toContain(join('pkg', 'AGENTS.md'))
|
||
expect(firstText).toContain('subtree rule')
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('parent rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('ignores prompt-text spoofs, malformed metadata, and metadata from other plugins', 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 package 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)
|
||
agent.session.append('user/message', createUserMessage({
|
||
content: [
|
||
{ type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' },
|
||
{ type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' },
|
||
],
|
||
source: {
|
||
kind: 'agent-instructions',
|
||
form: 'instructions',
|
||
changes: [
|
||
null,
|
||
{ action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') },
|
||
{ action: 'set', scope: 'pkg', path: 42 },
|
||
{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 42 },
|
||
],
|
||
} as never,
|
||
}), { surfaceOp: 'append' })
|
||
agent.session.append('user/message', createUserMessage({
|
||
content: [{ type: 'text', text: 'stale metadata version' }],
|
||
source: { kind: 'agent-instructions', changes: 'invalid' } as never,
|
||
}), { surfaceOp: 'append' })
|
||
agent.session.append('user/message', createUserMessage({
|
||
content: [{ type: 'text', text: 'foreign plugin context' }],
|
||
source: { kind: 'plugin', plugin: 'other' },
|
||
}), { surfaceOp: 'append' })
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-spoofed-state'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('loads nested instructions for absolute touched paths but not root-level files', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'root.txt'), 'root file')
|
||
await write(join(root, 'pkg/AGENTS.md'), 'nested package 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 rootResult = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-root-file'),
|
||
name: 'read',
|
||
arguments: { file_path: 'root.txt' },
|
||
agent,
|
||
})
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-absolute-nested-file'),
|
||
name: 'read',
|
||
arguments: { file_path: join(root, 'pkg/deep/file.txt') },
|
||
agent,
|
||
})
|
||
|
||
expect(rootResult.additionalContexts).toBeUndefined()
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule')
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('skips unreadable nested instruction files without attaching empty context', async () => {
|
||
// Cross-platform unreadable fixture: the provider read throws (chmod 0
|
||
// cannot make a file unreadable to its owner on Windows).
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
const nested = join(root, 'pkg/AGENTS.md')
|
||
await ctx.plugin(SystemPrompt)
|
||
await ctx.plugin(ToolRuntime)
|
||
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 })
|
||
const agent = stubAgent(root)
|
||
|
||
const result = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-with-unreadable-nested-instruction'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
await syncWorkspaceContext(ctx, agent)
|
||
|
||
expect(result.isError).toBe(false)
|
||
expect(result.additionalContexts).toBeUndefined()
|
||
await vi.waitFor(() => {
|
||
expect(fs.readTargets).toContain(nested)
|
||
})
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('preserves a downstream canonical value replacement while queuing workspace context separately', 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 package 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)
|
||
ctx.on('tools/post-execute', async () => ({
|
||
kind: 'accept' as const,
|
||
value: {
|
||
path: 'pkg/deep/file.txt',
|
||
offset: 1,
|
||
lines: [{ number: 1, text: 'downstream replacement' }],
|
||
totalLines: 1,
|
||
},
|
||
additionalContexts: [createUserMessage({
|
||
content: [{ type: 'text' as const, text: 'downstream context' }],
|
||
source: { kind: 'plugin' as const, plugin: 'downstream' },
|
||
})],
|
||
}))
|
||
|
||
const result = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-with-downstream'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
expect(result.isError).toBe(false)
|
||
if (result.isError) throw new Error('expected read replacement success')
|
||
expect(result.value).toEqual({
|
||
path: 'pkg/deep/file.txt',
|
||
offset: 1,
|
||
lines: [{ number: 1, text: 'downstream replacement' }],
|
||
totalLines: 1,
|
||
})
|
||
expect(blocksText(result.content)).toContain('downstream replacement')
|
||
expect(result.additionalContexts).toHaveLength(1)
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'agent-instructions' })
|
||
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
|
||
kind: 'agent-instructions',
|
||
form: 'instructions',
|
||
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
|
||
})
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule')
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).not.toContain('downstream context')
|
||
expect(result.additionalContexts?.[0]).toEqual({
|
||
id: expect.any(String) as unknown,
|
||
role: 'user',
|
||
content: [{ type: 'text', text: 'downstream context' }],
|
||
source: { kind: 'plugin', plugin: 'downstream' },
|
||
})
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not attach discovered instructions when a downstream listener blocks the tool call', 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 package 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)
|
||
ctx.on('tools/post-execute', async () => ({
|
||
kind: 'block' as const,
|
||
feedback: [{ type: 'text' as const, text: 'blocked downstream' }],
|
||
}))
|
||
|
||
const result = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-blocked-downstream'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
// The pipeline rejected this touch, so no workspace instructions from it
|
||
// should reach the model, and the block feedback must survive unchanged.
|
||
expect(result.isError).toBe(true)
|
||
expect(blocksText(result.content)).toBe('blocked downstream')
|
||
expect(result.additionalContexts).toBeUndefined()
|
||
await syncWorkspaceContext(ctx, agent)
|
||
expect(agent.inbox.nextStep).toEqual([])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not project a file touch when an outer post-execute listener blocks the final result', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||
await ctx.plugin(SystemPrompt)
|
||
await ctx.plugin(ToolRuntime)
|
||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||
await ctx.plugin(ToolFs)
|
||
let shouldBlock = true
|
||
ctx.on('tools/post-execute', async (_exec, _result, next) => {
|
||
const downstream = await next()
|
||
return shouldBlock
|
||
? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer policy block' }] }
|
||
: downstream
|
||
})
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
const blocked = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('outer-block-first'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
await syncWorkspaceContext(ctx, agent)
|
||
expect(agent.inbox.nextStep).toEqual([])
|
||
|
||
shouldBlock = false
|
||
const accepted = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('outer-block-retry'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
expect(blocked.isError).toBe(true)
|
||
expect(blocked.additionalContexts).toBeUndefined()
|
||
expect(accepted.isError).toBe(false)
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule')
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('projects a successful nested file result independently of a blocked composite result', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||
await ctx.plugin(SystemPrompt)
|
||
await ctx.plugin(ToolRuntime)
|
||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||
await ctx.plugin(ToolFs)
|
||
ctx.tools.register(defineContentToolFixture({
|
||
name: 'composite-read',
|
||
description: 'read through a nested dispatch',
|
||
parameters: {},
|
||
async execute(_args, exec) {
|
||
const nested = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId(`${exec.callId}:nested`),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
...exec.agent === undefined ? {} : { agent: exec.agent },
|
||
parent: exec.token,
|
||
...exec.signal === undefined ? {} : { signal: exec.signal },
|
||
})
|
||
for (const context of nested.additionalContexts ?? []) exec.deferContext(context)
|
||
return nested.content
|
||
},
|
||
}))
|
||
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
||
const downstream = await next()
|
||
return exec.name === 'composite-read'
|
||
? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer composite block' }] }
|
||
: downstream
|
||
})
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
|
||
const blocked = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('composite-first'), name: 'composite-read', arguments: {}, agent,
|
||
})
|
||
|
||
expect(blocked.isError).toBe(true)
|
||
expect(blocked.additionalContexts).toBeUndefined()
|
||
expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule')
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('defers a nested file projection until the enclosing step commits', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
|
||
const agent = stubAgent(root)
|
||
const turnStart = agent.session.append('turn/start', { turn: 1 })
|
||
ctx.emit('session/event', agent.session, turnStart)
|
||
const stepStart = agent.session.append('step/start', { turn: 1, step: 1 })
|
||
ctx.emit('session/event', agent.session, stepStart)
|
||
const outerToken = Symbol('outer-code-run') as ToolExecutionToken
|
||
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
token: Symbol('nested-read') as ToolExecutionToken,
|
||
parent: outerToken,
|
||
signal: testToolSignal,
|
||
callId: CallId('nested-read'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'file.txt') },
|
||
agent,
|
||
}), { content: [], isError: false, value: null })
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
token: Symbol('nested-non-file') as ToolExecutionToken,
|
||
parent: outerToken,
|
||
signal: testToolSignal,
|
||
callId: CallId('nested-non-file'),
|
||
name: 'search',
|
||
arguments: {},
|
||
agent,
|
||
}), { content: [], isError: false, value: null })
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
token: Symbol('second-nested-read') as ToolExecutionToken,
|
||
parent: outerToken,
|
||
signal: testToolSignal,
|
||
callId: CallId('second-nested-read'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'second.txt') },
|
||
agent,
|
||
}), { content: [], isError: false, value: null })
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
token: outerToken,
|
||
signal: testToolSignal,
|
||
callId: CallId('outer-code-run'),
|
||
name: 'run_code',
|
||
arguments: {},
|
||
agent,
|
||
}), { content: [], isError: false, value: null })
|
||
|
||
await syncWorkspaceContext(ctx, agent)
|
||
expect(agent.inbox.nextStep).toEqual([])
|
||
|
||
const stepEnd = agent.session.append('step/end', { turn: 1, step: 1 })
|
||
ctx.emit('session/event', agent.session, stepEnd)
|
||
expect(blocksText((await syncedWorkspaceContext(ctx, agent)).content))
|
||
.toContain('nested package rule')
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('seeds closed step state from existing session history', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
|
||
const agent = stubAgent(root)
|
||
agent.session.append('turn/start', { turn: 1 })
|
||
agent.session.append('step/start', { turn: 1, step: 1 })
|
||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-closed-step'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'file.txt') },
|
||
agent,
|
||
}), { content: [], isError: false, value: null })
|
||
|
||
expect(blocksText((await syncedWorkspaceContext(ctx, agent)).content))
|
||
.toContain('nested package rule')
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('ignores failed, aborted, agentless, and non-file final results', async () => {
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
const agent = stubAgent('/')
|
||
const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null }
|
||
const aborted = new AbortController()
|
||
aborted.abort(new Error('cancelled'))
|
||
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal, callId: CallId('agentless'), name: 'read', arguments: { file_path: 'file.txt' },
|
||
}), plainResult)
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal, callId: CallId('failed'), name: 'read', arguments: { file_path: 'failed/file.txt' }, agent,
|
||
}), { content: [], isError: true, error: { message: 'failed' } })
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: aborted.signal, callId: CallId('aborted'), name: 'read', arguments: { file_path: 'aborted/file.txt' }, agent,
|
||
}), plainResult)
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('null-arguments'), name: 'read', arguments: null, agent,
|
||
}), plainResult)
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('missing-path'), name: 'read', arguments: {}, agent,
|
||
}), plainResult)
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('non-string-path'), name: 'read', arguments: { file_path: 1 }, agent,
|
||
}), plainResult)
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('blank-path'), name: 'read', arguments: { file_path: ' ' }, agent,
|
||
}), plainResult)
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('non-fs'), name: 'composite', arguments: {}, agent,
|
||
}), plainResult)
|
||
|
||
await Promise.resolve()
|
||
expect(fs.signals).toEqual([])
|
||
expect(agent.inbox.nextStep).toEqual([])
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
}
|
||
})
|
||
|
||
it('warns when an asynchronous file-result projection fails', { timeout: 20_000 }, async () => {
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
const root = resolve('/')
|
||
const agent = stubAgent(root)
|
||
const failure = new Error('projection failed')
|
||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'workspace rule' })
|
||
vi.spyOn(agent.inbox, 'prepend').mockImplementationOnce(() => { throw failure })
|
||
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('projection-failure'),
|
||
name: 'read',
|
||
arguments: { file_path: 'file.txt' },
|
||
agent,
|
||
}), { content: [], isError: false, value: null })
|
||
|
||
await vi.waitFor(() => {
|
||
expect(warn).toHaveBeenCalledWith('workspace instruction refresh failed: %o', failure)
|
||
}, { timeout: 10_000 })
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
}
|
||
})
|
||
|
||
it('does not attach nested instructions when the byte budget is disabled', 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 package rule')
|
||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 0 })
|
||
|
||
const result = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-with-disabled-budget'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent: stubAgent(root),
|
||
})
|
||
|
||
expect(result.isError).toBe(false)
|
||
expect(result.additionalContexts).toBeUndefined()
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('retries a nested instruction touch when only a truncated budget notice was rendered', async () => {
|
||
const root = join(await tempRepo(), 'virtual-repo')
|
||
const home = join(await tempRepo(), 'virtual-home')
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(SystemPrompt)
|
||
await ctx.plugin(ToolRuntime)
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
const instructionPath = join(root, 'pkg/AGENTS.md')
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) })
|
||
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
|
||
await ctx.plugin(ToolFs)
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 })
|
||
const agent = stubAgent(root)
|
||
|
||
const first = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-tiny-budget-1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await syncWorkspaceContext(ctx, agent)
|
||
const second = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-tiny-budget-2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await syncWorkspaceContext(ctx, agent)
|
||
|
||
expect(first.additionalContexts).toBeUndefined()
|
||
expect(second.additionalContexts).toBeUndefined()
|
||
// Nothing was emitted, and the uncommitted version made the second sync
|
||
// probe the instruction file again — the retry.
|
||
expect(agent.inbox.nextStep).toHaveLength(0)
|
||
expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(dirname(root), { recursive: true, force: true })
|
||
await rm(dirname(home), { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('does not attach nested instructions after a failed file read', 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 package rule')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
|
||
const result = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-missing'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'missing.txt') },
|
||
agent: stubAgent(root),
|
||
})
|
||
|
||
expect(result.isError).toBe(true)
|
||
expect(result.additionalContexts).toBeUndefined()
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('cleans up its tools/result listener when the plugin fiber is disposed', 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 package rule')
|
||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||
const ctx = new Context()
|
||
const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
await fiber.dispose()
|
||
const agent = stubAgent(root)
|
||
|
||
const result = await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('read-after-dispose'),
|
||
name: 'read',
|
||
arguments: { file_path: join('pkg', 'deep', 'file.txt') },
|
||
agent,
|
||
})
|
||
|
||
expect(result.isError).toBe(false)
|
||
expect(result.additionalContexts).toBeUndefined()
|
||
await Promise.resolve()
|
||
expect(agent.inbox.nextStep).toEqual([])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('workspace context inbox synchronization', () => {
|
||
const acceptedResult = {
|
||
content: [{ type: 'text' as const, text: 'ok' }],
|
||
isError: false as const,
|
||
value: null,
|
||
}
|
||
|
||
it('keeps one reusable desired context when recovery contains an exact duplicate', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'AGENTS.md'), 'duplicate baseline')
|
||
const ctx = new Context()
|
||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
await syncWorkspaceContext(ctx, agent)
|
||
const desired = agent.inbox.nextStep[0]!
|
||
agent.inbox.append('next-step', createUserMessage({ content: desired.content, source: desired.source }))
|
||
|
||
await syncWorkspaceContext(ctx, agent)
|
||
|
||
expect(agent.inbox.nextStep).toHaveLength(1)
|
||
expect(agent.inbox.nextStep[0]?.id).toBe(desired.id)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('holds back a dynamic change a one-byte positive render budget cannot represent', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'tiny-budget rule' })
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 1 })
|
||
const agent = stubAgent(root)
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('tiny-budget-touch'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
}), acceptedResult)
|
||
|
||
await syncWorkspaceContext(ctx, agent)
|
||
|
||
// One byte cannot semantically represent the transition, so nothing is
|
||
// emitted and nothing commits — the uncommitted version retries on the
|
||
// next touch instead of committing state the model never saw.
|
||
expect(agent.inbox.nextStep).toHaveLength(0)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('settles same-scope replacement and deletion against files instead of pending prose', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), 'pending version one')
|
||
await write(join(root, 'pkg/file.txt'), 'file')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('pending-v1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await syncWorkspaceContext(ctx, agent)
|
||
expect(blocksText(agent.inbox.nextStep[0]?.content)).toContain('pending version one')
|
||
const duplicate = createUserMessage({
|
||
content: agent.inbox.nextStep[0]!.content,
|
||
source: agent.inbox.nextStep[0]!.source,
|
||
})
|
||
agent.inbox.append('next-step', duplicate)
|
||
|
||
await write(join(root, 'pkg/AGENTS.md'), 'pending version two with more detail')
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('pending-v2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await syncWorkspaceContext(ctx, agent)
|
||
expect(agent.inbox.nextStep).toHaveLength(1)
|
||
expect(agent.inbox.nextStep[0]?.id).not.toBe(duplicate.id)
|
||
expect(blocksText(agent.inbox.nextStep[0]?.content)).toContain('pending version two with more detail')
|
||
expect(blocksText(agent.inbox.nextStep[0]?.content)).not.toContain('pending version one')
|
||
|
||
await rm(join(root, 'pkg/AGENTS.md'))
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('pending-delete'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
|
||
})
|
||
await syncWorkspaceContext(ctx, agent)
|
||
expect(agent.inbox.nextStep).toEqual([])
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('keeps a completed tool projection when a later pre-step aborts', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'restored A' })
|
||
fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'restored B' })
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
const first = stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('projected-before-abort'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent,
|
||
})
|
||
ctx.emit('tools/result', first, acceptedResult)
|
||
const controller = new AbortController()
|
||
controller.abort(new Error('abort pre-step reconciliation'))
|
||
|
||
await expect(agentEvents(ctx, agent).waterfall(
|
||
'agent/pre-step', { messages: [], turn: 1, step: 1, signal: controller.signal },
|
||
async () => ({ kind: 'enter' as const, messages: [] }),
|
||
)).rejects.toThrow('abort pre-step reconciliation')
|
||
|
||
ctx.emit('tools/result', stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('projected-after-abort'), name: 'read', arguments: { file_path: join('b', 'file.txt') }, agent,
|
||
}), acceptedResult)
|
||
await syncWorkspaceContext(ctx, agent)
|
||
const text = blocksText(agent.inbox.nextStep[0]?.content)
|
||
expect(text).toContain('restored A')
|
||
expect(text).toContain('restored B')
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('serializes concurrent final results and merges both touched scopes into one pending context', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
const ctx = new Context()
|
||
try {
|
||
await ctx.plugin(RecordingFileSystem)
|
||
const fs = ctx.fs as RecordingFileSystem
|
||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||
fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'scope A' })
|
||
fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'scope B' })
|
||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(root)
|
||
const first = stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('concurrent-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent,
|
||
})
|
||
const second = stubToolExecution({
|
||
signal: testToolSignal,
|
||
callId: CallId('concurrent-b'), name: 'read', arguments: { file_path: join('b', 'file.txt') }, agent,
|
||
})
|
||
|
||
ctx.emit('tools/result', first, acceptedResult)
|
||
ctx.emit('tools/result', second, acceptedResult)
|
||
await syncWorkspaceContext(ctx, agent)
|
||
|
||
await vi.waitFor(() => {
|
||
expect(agent.inbox.nextStep).toHaveLength(1)
|
||
const text = blocksText(agent.inbox.nextStep[0]?.content)
|
||
expect(text).toContain('scope A')
|
||
expect(text).toContain('scope B')
|
||
})
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('merges a recovered pending context with a fresh touched scope', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'a/AGENTS.md'), 'recovered scope A')
|
||
await write(join(root, 'a/file.txt'), 'a')
|
||
await write(join(root, 'b/AGENTS.md'), 'fresh scope B')
|
||
await write(join(root, 'b/file.txt'), 'b')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const original = stubAgent(root)
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original,
|
||
})
|
||
await syncWorkspaceContext(ctx, original)
|
||
const resumed = stubAgent(root, [...original.session.events])
|
||
|
||
await ctx.tools.execute({
|
||
signal: testToolSignal,
|
||
callId: CallId('recover-pending-b'), name: 'read', arguments: { file_path: join('b', 'file.txt') }, agent: resumed,
|
||
})
|
||
await syncWorkspaceContext(ctx, resumed)
|
||
|
||
await vi.waitFor(() => {
|
||
expect(resumed.inbox.nextStep).toHaveLength(1)
|
||
const text = blocksText(resumed.inbox.nextStep[0]?.content)
|
||
expect(text).toContain('recovered scope A')
|
||
expect(text).toContain('fresh scope B')
|
||
})
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('enters an offline correction immediately after its claimed stale context', async () => {
|
||
const root = await tempRepo()
|
||
const home = await tempRepo()
|
||
try {
|
||
await mkdir(join(root, '.git'), { recursive: true })
|
||
await write(join(root, 'pkg/AGENTS.md'), 'old claimed rule')
|
||
await write(join(root, 'pkg/file.txt'), 'file')
|
||
const ctx = new Context()
|
||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||
const agent = stubAgent(join(root, 'pkg'))
|
||
await syncedWorkspaceContext(ctx, agent)
|
||
const claimed = agent.inbox.claim('next-step', 1)
|
||
await write(join(root, 'pkg/AGENTS.md'), 'new claimed rule with more detail')
|
||
const downstream = { kind: 'enter' as const, messages: claimed }
|
||
|
||
const decision = await agentEvents(ctx, agent).waterfall(
|
||
'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: testToolSignal },
|
||
async () => downstream,
|
||
)
|
||
|
||
if (decision.kind !== 'enter') throw new Error('offline correction was rejected')
|
||
expect(decision.messages).toHaveLength(2)
|
||
expect(decision.messages[0]).toBe(claimed[0])
|
||
expect(blocksText(decision.messages[0]?.content)).toContain('old claimed rule')
|
||
expect(blocksText(decision.messages[1]?.content)).toContain('new claimed rule with more detail')
|
||
expect(agent.inbox.nextStep).toHaveLength(0)
|
||
} finally {
|
||
await rm(root, { recursive: true, force: true })
|
||
await rm(home, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
})
|
||
|
||
describe('workspace context plugin export shape', () => {
|
||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
|
||
expect('default' in workspaceContext).toBe(false)
|
||
expect(typeof workspaceContext.apply).toBe('function')
|
||
|
||
const loader = Object.create(Loader.prototype) as Loader
|
||
const unwrapped = loader.unwrapExports(workspaceContext) as Record<string, unknown>
|
||
expect(unwrapped).toBe(workspaceContext)
|
||
expect(unwrapped.name).toBe('agent-instructions')
|
||
expect(unwrapped.Config).toBeDefined()
|
||
expect(typeof unwrapped.apply).toBe('function')
|
||
})
|
||
})
|