|
|
@@ -955,6 +955,267 @@ describe('workspace context request injection', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
+ 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 === 'workspace-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 === 'workspace-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 === 'workspace-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 === 'workspace-instructions'
|
|
|
+ && event.data.source.baseline !== true)
|
|
|
+ expect(update?.type === 'user/message' && update.data.source.kind === 'workspace-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 === 'workspace-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 === 'workspace-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 === 'workspace-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 === 'workspace-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()
|
|
|
@@ -1366,7 +1627,7 @@ describe('workspace context request injection', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('recomposes the baseline from current files when a resumed session edited it offline', async () => {
|
|
|
+ it('appends a replacement transition when a resumed session edited its baseline offline', async () => {
|
|
|
const root = await tempRepo()
|
|
|
const home = await tempRepo()
|
|
|
try {
|
|
|
@@ -1377,13 +1638,8 @@ describe('workspace context request injection', () => {
|
|
|
const original = stubAgent(root)
|
|
|
await composeBaselinePrefix(ctx, original)
|
|
|
|
|
|
- // Offline edit to the baseline file, then resume on a fresh session whose
|
|
|
- // seeded log already carries the original baseline. A resumed session is
|
|
|
- // registered after this mount's apply(), so the remount guard never seeds
|
|
|
- // it: its first step re-composes a fresh baseline from current files,
|
|
|
- // reflecting the offline edit before the first resumed request. The old
|
|
|
- // baseline stays in history unmutated (note: resume without mutating an
|
|
|
- // earlier history event).
|
|
|
+ // 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])
|
|
|
|
|
|
@@ -1395,6 +1651,9 @@ describe('workspace context request injection', () => {
|
|
|
expect(baselines).toHaveLength(1)
|
|
|
const latest = resumed.session.events.findLast(event =>
|
|
|
event.type === 'user/message' && event.data.source.kind === 'workspace-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]
|
|
|
@@ -2799,6 +3058,80 @@ describe('dynamic nested workspace context injection', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
+ 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: 'workspace-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()
|