Fix Code Mode workspace context propagation

This commit is contained in:
Yichen Jiang
2026-07-13 13:56:45 +08:00
parent c748f30055
commit 768c79fd45
51 changed files with 1040 additions and 265 deletions

View File

@@ -139,15 +139,22 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri
return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? ''
}
function appendAdditionalContext(agent: Agent, result: { additionalContext?: HookContext }): number | undefined {
const context = result.additionalContext
if (context === undefined) return undefined
return agent.session.append('context/message', {
content: context.content,
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' }).seq
function workspaceContextOf(result: { additionalContexts?: HookContext[] }): HookContext | undefined {
return result.additionalContexts?.find(context =>
context.source.kind === 'plugin' && context.source.plugin === 'workspace-context')
}
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined {
let lastSeq: number | undefined
for (const context of result.additionalContexts ?? []) {
lastSeq = agent.session.append('context/message', {
content: context.content,
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' }).seq
}
return lastSeq
}
const composedPrefixes = new WeakMap<object, Message[]>()
@@ -763,7 +770,7 @@ describe('workspace context request injection', () => {
kind: 'block',
feedback: [{ type: 'text', text: 'blocked by policy' }],
})
expect(blocked.additionalContext).toBeUndefined()
expect(blocked.additionalContexts).toBeUndefined()
// The same read, when the downstream accepts, DOES surface the nested
// instructions — proving the block branch above is what suppressed them,
@@ -772,8 +779,8 @@ describe('workspace context request injection', () => {
kind: 'accept' as const,
}))
expect(accepted.kind).toBe('accept')
expect(accepted.additionalContext?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(blocksText(accepted.additionalContext?.content)).toContain('nested package rule')
expect(workspaceContextOf(accepted)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule')
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
@@ -891,11 +898,11 @@ describe('workspace context request injection', () => {
callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
expect(result.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(result)?.meta).toMatchObject({
changes: [{ action: 'replace', scope: '.', path: 'AGENTS.md' }],
})
expect(blocksText(result.additionalContext?.content)).toContain('Updated instructions from: AGENTS.md')
expect(blocksText(result.additionalContext?.content)).toContain('new root rule with more detail')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('new root rule with more detail')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -919,10 +926,10 @@ describe('workspace context request injection', () => {
callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
expect(result.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(result)?.meta).toMatchObject({
changes: [{ action: 'remove', scope: '.', path: 'AGENTS.md' }],
})
expect(blocksText(result.additionalContext?.content)).toContain('Instructions removed: AGENTS.md')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -945,7 +952,7 @@ describe('workspace context request injection', () => {
})
expect(derivedText(agent).match(/shared root and global rule/g)).toHaveLength(1)
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
}
@@ -1361,9 +1368,9 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(result.additionalContext?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(result.additionalContext?.envelope).toBe('raw')
expect(result.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.envelope).toBe('raw')
expect(workspaceContextOf(result)?.meta).toMatchObject({
kind: 'workspace-instructions',
version: 1,
changes: [{
@@ -1372,7 +1379,7 @@ describe('dynamic nested workspace context injection', () => {
path: 'pkg/AGENTS.md',
}],
})
const meta = result.additionalContext?.meta
const meta = workspaceContextOf(result)?.meta
const firstChange = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes)
? meta.changes[0]
: undefined
@@ -1380,7 +1387,7 @@ describe('dynamic nested workspace context injection', () => {
? firstChange.digest
: undefined
expect(changeDigest).toMatch(/^[a-f0-9]{40}$/)
const text = blocksText(result.additionalContext?.content)
const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toBe([
'<system-reminder>',
'Additional instructions from: pkg/AGENTS.md',
@@ -1420,7 +1427,7 @@ describe('dynamic nested workspace context injection', () => {
agent: stubAgent(root),
})
const text = blocksText(result.additionalContext?.content)
const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md')
expect(text).toContain('local package rule')
expect(text).not.toContain('native package rule')
@@ -1454,8 +1461,8 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
expect(first.additionalContext).toBeDefined()
expect(second.additionalContext).toBeUndefined()
expect(first.additionalContexts).toBeDefined()
expect(second.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1476,17 +1483,17 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail')
const changed = await ctx.tools.execute({
callId: CallId('read-after-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(changed.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(changed)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }],
})
expect(blocksText(changed.additionalContext?.content)).toBe([
expect(blocksText(workspaceContextOf(changed)?.content)).toBe([
'<system-reminder>',
'Updated instructions from: pkg/AGENTS.md',
'',
@@ -1516,25 +1523,25 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
await rm(join(root, 'pkg/AGENTS.md'))
const changed = await ctx.tools.execute({
callId: CallId('read-after-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, changed)
appendAdditionalContexts(agent, changed)
const unchanged = await ctx.tools.execute({
callId: CallId('read-after-logged-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(changed.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(changed)?.meta).toMatchObject({
changes: [{
action: 'replace', scope: 'pkg', path: 'pkg/CLAUDE.md', previousPath: 'pkg/AGENTS.md',
}],
})
expect(blocksText(changed.additionalContext?.content)).toContain('Updated instructions from: pkg/CLAUDE.md')
expect(blocksText(changed.additionalContext?.content)).toContain('The instructions previously loaded from `pkg/AGENTS.md` no longer apply. Use the following content for `pkg` instead.')
expect(blocksText(changed.additionalContext?.content)).toContain('fallback package rule')
expect(unchanged.additionalContext).toBeUndefined()
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('Updated instructions from: pkg/CLAUDE.md')
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('The instructions previously loaded from `pkg/AGENTS.md` no longer apply. Use the following content for `pkg` instead.')
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('fallback package rule')
expect(unchanged.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1555,18 +1562,18 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
await rm(join(root, 'pkg/AGENTS.md'))
const removed = await ctx.tools.execute({
callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(removed.additionalContext?.meta).toEqual({
expect(workspaceContextOf(removed)?.meta).toEqual({
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'remove', scope: 'pkg', path: 'pkg/AGENTS.md' }],
})
expect(blocksText(removed.additionalContext?.content)).toBe([
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
'<system-reminder>',
'Instructions removed: pkg/AGENTS.md',
'',
@@ -1593,23 +1600,23 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
await rm(join(root, 'pkg/AGENTS.md'))
const removed = await ctx.tools.execute({
callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, removed)
appendAdditionalContexts(agent, removed)
await write(join(root, 'pkg/AGENTS.md'), 'restored package rule')
const restored = await ctx.tools.execute({
callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(restored.additionalContext?.meta).toMatchObject({
expect(workspaceContextOf(restored)?.meta).toMatchObject({
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
})
expect(blocksText(restored.additionalContext?.content)).toContain('Additional instructions from: pkg/AGENTS.md')
expect(blocksText(restored.additionalContext?.content)).toContain('restored package rule')
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md')
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1635,14 +1642,14 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
const duringFailure = await ctx.tools.execute({
callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
expect(first.additionalContext).toBeDefined()
expect(duringFailure.additionalContext).toBeUndefined()
expect(first.additionalContexts).toBeDefined()
expect(duringFailure.additionalContexts).toBeUndefined()
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
@@ -1666,7 +1673,7 @@ describe('dynamic nested workspace context injection', () => {
arguments: { file_path: 'pkg/deep/file.txt' },
agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
const resumed = {
...agent,
session: new Session(agent.session.id, [...agent.session.events], agent.session.header),
@@ -1679,8 +1686,8 @@ describe('dynamic nested workspace context injection', () => {
agent: resumed,
})
expect(first.additionalContext).toBeDefined()
expect(afterResume.additionalContext).toBeUndefined()
expect(first.additionalContexts).toBeDefined()
expect(afterResume.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1700,7 +1707,7 @@ describe('dynamic nested workspace context injection', () => {
const first = await ctx.tools.execute({
callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original,
})
appendAdditionalContext(original, first)
appendAdditionalContexts(original, first)
await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume')
const resumed = stubAgent(root, [...original.session.events])
@@ -1733,7 +1740,7 @@ describe('dynamic nested workspace context injection', () => {
arguments: { file_path: 'pkg/deep/file.txt' },
agent,
})
const contextSeq = appendAdditionalContext(agent, first)!
const contextSeq = appendAdditionalContexts(agent, first)!
const visibleBeforeCompact = await ctx.tools.execute({
callId: CallId('read-while-visible'),
name: 'read',
@@ -1753,10 +1760,10 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
expect(first.additionalContext).toBeDefined()
expect(visibleBeforeCompact.additionalContext).toBeUndefined()
expect(afterCompact.additionalContext).toBeDefined()
expect(blocksText(afterCompact.additionalContext?.content)).toContain('nested package rule')
expect(first.additionalContexts).toBeDefined()
expect(visibleBeforeCompact.additionalContexts).toBeUndefined()
expect(afterCompact.additionalContexts).toBeDefined()
expect(blocksText(workspaceContextOf(afterCompact)?.content)).toContain('nested package rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1781,7 +1788,7 @@ describe('dynamic nested workspace context injection', () => {
arguments: { file_path: 'pkg/file.txt' },
agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
const second = await ctx.tools.execute({
callId: CallId('read-subtree'),
@@ -1790,8 +1797,8 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
expect(blocksText(first.additionalContext?.content)).toContain('package note')
expect(blocksText(second.additionalContext?.content)).toContain('subtree rule')
expect(blocksText(workspaceContextOf(first)?.content)).toContain('package note')
expect(blocksText(workspaceContextOf(second)?.content)).toContain('subtree rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1816,7 +1823,7 @@ describe('dynamic nested workspace context injection', () => {
arguments: { file_path: 'pkg/sub/file.txt' },
agent,
})
appendAdditionalContext(agent, first)
appendAdditionalContexts(agent, first)
const second = await ctx.tools.execute({
callId: CallId('read-parent-after-omit'),
@@ -1825,11 +1832,11 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
const firstText = blocksText(first.additionalContext?.content)
const firstText = blocksText(workspaceContextOf(first)?.content)
expect(firstText).toContain('omitted pkg/AGENTS.md')
expect(firstText).not.toContain('## pkg/AGENTS.md')
expect(firstText).toContain('subtree rule')
expect(blocksText(second.additionalContext?.content)).toContain('parent rule')
expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1886,7 +1893,7 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
expect(blocksText(result.additionalContext?.content)).toContain('nested package rule')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1918,8 +1925,8 @@ describe('dynamic nested workspace context injection', () => {
agent,
})
expect(rootResult.additionalContext).toBeUndefined()
expect(blocksText(absoluteResult.additionalContext?.content)).toContain('nested package rule')
expect(rootResult.additionalContexts).toBeUndefined()
expect(blocksText(workspaceContextOf(absoluteResult)?.content)).toContain('nested package rule')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1982,7 +1989,7 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
await chmod(nested, 0o600)
} finally {
await rm(root, { recursive: true, force: true })
@@ -1990,7 +1997,7 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('folds nested instruction context with downstream post-execute content and context', async () => {
it('preserves nested and downstream post-execute contexts as separate entries', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -2002,10 +2009,10 @@ describe('dynamic nested workspace context injection', () => {
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
content: [{ type: 'text' as const, text: 'downstream replacement' }],
additionalContext: {
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream context' }],
source: { kind: 'plugin' as const, plugin: 'downstream' },
},
}],
}))
const result = await ctx.tools.execute({
@@ -2016,17 +2023,22 @@ describe('dynamic nested workspace context injection', () => {
})
expect(blocksText(result.content)).toBe('downstream replacement')
expect(result.additionalContext?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(result.additionalContext?.envelope).toBe('raw')
expect(result.additionalContext?.meta).toMatchObject({
expect(result.additionalContexts).toHaveLength(2)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.envelope).toBe('raw')
expect(workspaceContextOf(result)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
})
expect(blocksText(result.additionalContext?.content)).toContain('nested package rule')
expect(blocksText(result.additionalContext?.content)).toContain('downstream context')
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context')
expect(result.additionalContexts?.[1]).toEqual({
content: [{ type: 'text', text: 'downstream context' }],
source: { kind: 'plugin', plugin: 'downstream' },
})
const agent = stubAgent(root)
appendAdditionalContext(agent, result)
expect(blocksText(agent.session.deriveMessages()[0]?.content)).toContain('<context source="plugin">\ndownstream context\n</context>')
appendAdditionalContexts(agent, result)
expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('<context source="plugin">\ndownstream context\n</context>')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2058,7 +2070,7 @@ describe('dynamic nested workspace context injection', () => {
// 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.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2122,7 +2134,7 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2146,7 +2158,7 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(true)
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2172,7 +2184,7 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(result.additionalContext).toBeUndefined()
expect(result.additionalContexts).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })