|
@@ -40,7 +40,7 @@ import {
|
|
|
type InstructionVersionCache,
|
|
type InstructionVersionCache,
|
|
|
} from '../src/state.ts'
|
|
} from '../src/state.ts'
|
|
|
import { resolveConfig } from '../src/config.ts'
|
|
import { resolveConfig } from '../src/config.ts'
|
|
|
-import { candidateScopeKey, renderInstructionChanges, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } from '../src/render.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'
|
|
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
|
|
|
|
|
|
|
/** Per-candidate reconciliation scope key: directory paired with the file name. */
|
|
/** Per-candidate reconciliation scope key: directory paired with the file name. */
|
|
@@ -855,6 +855,33 @@ describe('workspace context rendering', () => {
|
|
|
expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(120)
|
|
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', () => {
|
|
it('truncates the compact notice itself when the render budget is smaller than the notice', () => {
|
|
|
const rendered = renderWorkspaceContext([
|
|
const rendered = renderWorkspaceContext([
|
|
|
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
|
{ absolutePath: '/repo/pkg/AGENTS.md', displayPath: 'pkg/AGENTS.md', content: 'x'.repeat(1000) },
|
|
@@ -865,6 +892,76 @@ describe('workspace context rendering', () => {
|
|
|
expect(Buffer.byteLength(rendered.text, 'utf8')).toBe(20)
|
|
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('�')
|
|
|
|
|
+ expect(rendered.text).not.toContain('😀')
|
|
|
|
|
+ expect(rendered.changes).toEqual([])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
it('keeps compact truncation notices within budget when a multibyte display path is cut', () => {
|
|
it('keeps compact truncation notices within budget when a multibyte display path is cut', () => {
|
|
|
const rendered = renderWorkspaceContext([
|
|
const rendered = renderWorkspaceContext([
|
|
|
{ absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) },
|
|
{ absolutePath: '/repo/路径/AGENTS.md', displayPath: '路径/AGENTS.md', content: 'x'.repeat(1000) },
|
|
@@ -1832,21 +1929,30 @@ describe('workspace context request injection', () => {
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('does not expose state markers when a tiny budget reduces the baseline contribution', async () => {
|
|
|
|
|
|
|
+ it.each([10, 120])('does not expose state markers when baseline content is omitted at %i bytes', async (maxBytes) => {
|
|
|
const root = await tempRepo()
|
|
const root = await tempRepo()
|
|
|
const home = await tempRepo()
|
|
const home = await tempRepo()
|
|
|
try {
|
|
try {
|
|
|
await mkdir(join(root, '.git'), { recursive: true })
|
|
await mkdir(join(root, '.git'), { recursive: true })
|
|
|
- await write(join(root, 'AGENTS.md'), 'repo rule')
|
|
|
|
|
|
|
+ await write(join(root, 'AGENTS.md'), 'x'.repeat(1000))
|
|
|
const ctx = new Context()
|
|
const ctx = new Context()
|
|
|
- await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 10 })
|
|
|
|
|
|
|
+ await mountWorkspaceContext(ctx, { dshHome: home, maxBytes })
|
|
|
const agent = stubAgent(root)
|
|
const agent = stubAgent(root)
|
|
|
|
|
|
|
|
await composeBaselinePrefix(ctx, agent)
|
|
await composeBaselinePrefix(ctx, agent)
|
|
|
|
|
|
|
|
- expect(agent.session.events.filter(event =>
|
|
|
|
|
|
|
+ const contexts = agent.session.events.filter(event =>
|
|
|
event.type === 'user/message' && event.data.source.kind !== 'user',
|
|
event.type === 'user/message' && event.data.source.kind !== 'user',
|
|
|
- )).toHaveLength(1)
|
|
|
|
|
|
|
+ )
|
|
|
|
|
+ expect(contexts).toHaveLength(1)
|
|
|
|
|
+ const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined
|
|
|
|
|
+ expect(source?.kind === 'workspace-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('workspace-context:')
|
|
expect(derivedText(agent)).not.toContain('workspace-context:')
|
|
|
} finally {
|
|
} finally {
|
|
|
await rm(root, { recursive: true, force: true })
|
|
await rm(root, { recursive: true, force: true })
|
|
@@ -4167,6 +4273,47 @@ describe('dynamic nested workspace context injection', () => {
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ 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(ToolRegistry)
|
|
|
|
|
+ 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 () => {
|
|
it('does not attach nested instructions after a failed file read', async () => {
|
|
|
const root = await tempRepo()
|
|
const root = await tempRepo()
|
|
|
const home = await tempRepo()
|
|
const home = await tempRepo()
|
|
@@ -4253,7 +4400,7 @@ describe('workspace context inbox synchronization', () => {
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- it('keeps a dynamic change within a one-byte positive render budget', async () => {
|
|
|
|
|
|
|
+ it('holds back a dynamic change a one-byte positive render budget cannot represent', async () => {
|
|
|
const root = await tempRepo()
|
|
const root = await tempRepo()
|
|
|
const home = await tempRepo()
|
|
const home = await tempRepo()
|
|
|
const ctx = new Context()
|
|
const ctx = new Context()
|
|
@@ -4271,8 +4418,10 @@ describe('workspace context inbox synchronization', () => {
|
|
|
|
|
|
|
|
await syncWorkspaceContext(ctx, agent)
|
|
await syncWorkspaceContext(ctx, agent)
|
|
|
|
|
|
|
|
- expect(agent.inbox.nextStep).toHaveLength(1)
|
|
|
|
|
- expect(Buffer.byteLength(blocksText(agent.inbox.nextStep[0]?.content), 'utf8')).toBeLessThanOrEqual(1)
|
|
|
|
|
|
|
+ // 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 {
|
|
} finally {
|
|
|
await ctx.fiber.dispose()
|
|
await ctx.fiber.dispose()
|
|
|
await rm(root, { recursive: true, force: true })
|
|
await rm(root, { recursive: true, force: true })
|