diff --git a/examples/acp-agent/code-mode.cordis.snapshot.yml b/examples/acp-agent/code-mode.cordis.snapshot.yml index 7d20168ff5..dcbdf05b2a 100644 --- a/examples/acp-agent/code-mode.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode.cordis.snapshot.yml @@ -17,8 +17,14 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - workspaceContext: - maxBytes: 65536 + # Disabled in Code Mode: fs tools run as run_code sub-dispatches and + # code-mode.ts deliberately drops sub-call `additionalContext`, so the + # nested/changed/removed AGENTS.md notices this feature emits after + # read/write/edit are discarded before the loop can append them. + # Enabling it would only ship the baseline prefix while silently + # dropping the dynamic updates, so keep it off until sub-dispatch + # context propagation lands. + workspaceContext: false tools: mode: code persona: | diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index a32254a387..7dbc594fb9 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -17,8 +17,14 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - workspaceContext: - maxBytes: 65536 + # Disabled in Code Mode: fs tools run as run_code sub-dispatches and + # code-mode.ts deliberately drops sub-call `additionalContext`, so the + # nested/changed/removed AGENTS.md notices this feature emits after + # read/write/edit are discarded before the loop can append them. + # Enabling it would only ship the baseline prefix while silently + # dropping the dynamic updates, so keep it off until sub-dispatch + # context propagation lands. + workspaceContext: false tools: mode: code persona: | diff --git a/examples/coding-agent/code-mode.cordis.yml b/examples/coding-agent/code-mode.cordis.yml index 81d80a5eef..0c84286668 100644 --- a/examples/coding-agent/code-mode.cordis.yml +++ b/examples/coding-agent/code-mode.cordis.yml @@ -19,8 +19,14 @@ model: deepseek-v4-flash resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 + # Disabled in Code Mode: fs tools run as run_code sub-dispatches and + # code-mode.ts deliberately drops sub-call `additionalContext`, so the + # nested/changed/removed AGENTS.md notices this feature emits after + # read/write/edit are discarded before the loop can append them. + # Enabling it would only ship the baseline prefix while silently + # dropping the dynamic updates, so keep it off until sub-dispatch + # context propagation lands. + workspaceContext: false tools: mode: code welcome: 'code-mode agent ready. Give it a multi-tool task.' diff --git a/packages/prompt/workspace-context/src/index.ts b/packages/prompt/workspace-context/src/index.ts index a4aeffae71..b61860a1e6 100644 --- a/packages/prompt/workspace-context/src/index.ts +++ b/packages/prompt/workspace-context/src/index.ts @@ -91,6 +91,13 @@ export function apply(ctx: Context, config: Config): void { next, ): Promise => { const downstream = await next() + // A downstream listener/policy blocked this call: the registry turns it + // into a final `isError` result, so treat it like a failed fs touch and + // load nothing. Reconciling here would surface workspace instructions from + // a call the pipeline rejected, violating the "successful fs tool touches" + // contract, and would advance the nested/baseline tracking state off a + // touch that never really happened. + if (downstream.kind === 'block') return downstream const fileSystem = ctx.get('fs') if (fileSystem === undefined) return downstream const context = await dynamicInstructionContext( @@ -104,14 +111,10 @@ export function apply(ctx: Context, config: Config): void { fileSystem, ) if (context === undefined) return downstream - const additionalContext = concatContext(context, downstream.additionalContext) - if (downstream.kind === 'block') { - return { kind: 'block', feedback: downstream.feedback, additionalContext } - } return { kind: 'accept', ...downstream.content !== undefined ? { content: downstream.content } : {}, - additionalContext, + additionalContext: concatContext(context, downstream.additionalContext), } }) } diff --git a/packages/prompt/workspace-context/tests/workspace-context.spec.ts b/packages/prompt/workspace-context/tests/workspace-context.spec.ts index 204464cc63..59440dc038 100644 --- a/packages/prompt/workspace-context/tests/workspace-context.spec.ts +++ b/packages/prompt/workspace-context/tests/workspace-context.spec.ts @@ -725,6 +725,62 @@ describe('workspace context request injection', () => { } }) + it('does not load workspace instructions when a downstream listener blocks the tool call', async () => { + const root = await tempRepo() + const home = await tempRepo() + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + 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' }) + 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 exec = { + callId: CallId('read-blocked-post-execute'), + name: 'read', + arguments: { file_path: 'pkg/file.txt' }, + agent, + } + const result = { + callId: CallId('read-blocked-post-execute'), + isError: false, + content: [{ type: 'text' as const, text: 'hello' }], + } + + // A later PostToolUse-style policy blocks this otherwise-successful read. + const blocked = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ + kind: 'block' as const, + feedback: [{ type: 'text' as const, text: 'blocked by policy' }], + })) + + expect(blocked).toEqual({ + kind: 'block', + feedback: [{ type: 'text', text: 'blocked by policy' }], + }) + expect(blocked.additionalContext).toBeUndefined() + + // The same read, when the downstream accepts, DOES surface the nested + // instructions — proving the block branch above is what suppressed them, + // and that the block did not consume the pending nested change. + const accepted = await ctx.waterfall('tools/post-execute', exec, result, async () => ({ + 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') + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('contributes baseline instructions through the frozen session prefix instead of durable history', async () => { const root = await tempRepo() const home = await tempRepo() @@ -1977,7 +2033,7 @@ describe('dynamic nested workspace context injection', () => { } }) - it('keeps downstream post-execute blocks while still attaching discovered instructions', async () => { + it('does not attach discovered instructions when a downstream listener blocks the tool call', async () => { const root = await tempRepo() const home = await tempRepo() try { @@ -1998,12 +2054,11 @@ describe('dynamic nested workspace context injection', () => { agent: stubAgent(root), }) + // 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(blocksText(result.additionalContext?.content)).toContain('nested package rule') - expect(result.additionalContext?.meta).toMatchObject({ - changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }], - }) + expect(result.additionalContext).toBeUndefined() } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true })