Fix workspace context review findings

This commit is contained in:
Yichen Jiang
2026-07-13 16:31:03 +08:00
parent adf6b8a1ab
commit aa62b5109a
40 changed files with 708 additions and 252 deletions

View File

@@ -22,15 +22,19 @@ import type {
} from '@deepseek-ai/dsh-fs'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool } 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,
type InstructionContentCache,
} from '@deepseek-ai/dsh-workspace-context'
import {
commitPendingInstructionContexts,
rollbackPendingInstructionChanges,
type PendingInstructionChange,
} from '../src/state.ts'
async function tempRepo(): Promise<string> {
return mkdtemp(join(tmpdir(), 'dsh-workspace-context-'))
@@ -45,14 +49,21 @@ class RecordingFileSystem extends FileSystem {
entries = new Map<string, { type: FsInfo['type']; content?: string }>()
lstatTypes = new Map<string, FsPathInfo['type']>()
throwOnStat = new Set<string>()
omitSizes = new Set<string>()
readTargets: string[] = []
readTextTargets: string[] = []
signals: AbortSignal[] = []
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
if (opts?.signal !== undefined) this.signals.push(opts.signal)
opts?.signal?.throwIfAborted()
const absolute = join(opts?.cwd ?? '/', path)
return { targetKey: FsTargetKey(absolute), displayPath: absolute }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
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
@@ -60,15 +71,17 @@ class RecordingFileSystem extends FileSystem {
version: FsVersion(`v:${target.targetKey}`),
type: entry.type,
}
if (entry.content !== undefined) info.size = Buffer.byteLength(entry.content, 'utf8')
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 }): Promise<FsPathInfo | undefined> {
const target = await this.resolve(path, opts)
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 lstatType = this.lstatTypes.get(target.targetKey)
if (lstatType !== undefined) return { version: FsVersion(`lstat:${target.targetKey}`), type: lstatType }
const info = await this.stat(target)
const info = await this.stat(target, signal)
if (info === undefined) return undefined
return {
version: info.version,
@@ -77,14 +90,24 @@ class RecordingFileSystem extends FileSystem {
}
}
override async readText(target: FsTarget): Promise<string> {
this.readTargets.push(target.targetKey)
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 streamText(target: FsTarget): Promise<AsyncIterable<string>> {
const content = await this.readText(target)
return (async function* () { yield content })()
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
if (signal !== undefined) this.signals.push(signal)
signal?.throwIfAborted()
this.readTargets.push(target.targetKey)
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[]> {
@@ -100,6 +123,24 @@ class RecordingFileSystem extends FileSystem {
}
}
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)
@@ -153,6 +194,19 @@ function workspaceContextOf(result: { additionalContexts?: HookContext[] }): Hoo
context.source.kind === 'plugin' && context.source.plugin === 'workspace-context')
}
function workspaceChangeContext(scope: string, digest: string): HookContext {
return {
content: [{ type: 'text', text: `instructions for ${scope}` }],
source: { kind: 'plugin', plugin: 'workspace-context' },
envelope: 'raw',
meta: {
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }],
},
}
}
function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: HookContext[] }): number | undefined {
let lastSeq: number | undefined
for (const context of result.additionalContexts ?? []) {
@@ -235,7 +289,7 @@ describe('workspace context instruction discovery', () => {
}
})
it('refreshes cached content after a same-version, same-size rewrite', async () => {
it('re-reads content after a same-version, same-size rewrite', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -243,20 +297,19 @@ describe('workspace context instruction discovery', () => {
await mkdir(join(root, '.git'), { recursive: true })
await mkdir(cwd, { recursive: true })
const cache: InstructionContentCache = new Map()
expect(await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })).toBeUndefined()
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, cache })
const first = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
expect(first?.text).toContain('first')
const cached = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })
expect(cached?.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, cache })
const second = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
expect(second?.text).toContain('other')
expect(second?.text).not.toContain('first')
} finally {
@@ -337,6 +390,10 @@ describe('workspace context instruction discovery', () => {
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 })
@@ -1035,6 +1092,84 @@ describe('workspace context request injection', () => {
}
})
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])
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 session-prefix 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 empty: Message[] = []
const pending = ctx.waterfall(
'agent/session-prefix', stubAgent(root), empty, controller.signal,
() => Promise.resolve(empty),
)
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()
@@ -1340,11 +1475,9 @@ describe('workspace context request injection', () => {
}
})
const isolated = await import('@deepseek-ai/dsh-workspace-context')
const cache: InstructionContentCache = new Map()
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, cache })
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
observedStats.clear()
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, cache })
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1)
} finally {
@@ -1357,6 +1490,42 @@ describe('workspace context request injection', () => {
})
describe('dynamic nested workspace context injection', () => {
it('propagates the tool execution signal into dynamic filesystem 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, '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: 'pkg/file.txt' },
agent: stubAgent(root),
signal: controller.signal,
})
const pending = ctx.waterfall('tools/post-execute', exec, {
callId: exec.callId,
content: [{ type: 'text', text: 'ok' }],
isError: false,
}, () => Promise.resolve({ kind: 'accept' as const }))
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('attaches newly discovered nested instructions after a successful file read touches a descendant path', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -2086,6 +2255,142 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('does not commit pending state 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(ToolRegistry)
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({
callId: CallId('outer-block-first'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
agent,
})
shouldBlock = false
const accepted = await ctx.tools.execute({
callId: CallId('outer-block-retry'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
agent,
})
expect(blocked.isError).toBe(true)
expect(blocked.additionalContexts).toBeUndefined()
expect(accepted.isError).toBe(false)
expect(blocksText(workspaceContextOf(accepted)?.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('rolls back parent-token pending state when a composite result is blocked', 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(ToolRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
ctx.tools.register(defineTool({
name: 'composite-read',
description: 'read through a nested dispatch',
parameters: {},
async execute(_args, exec) {
const nested = await ctx.tools.execute({
callId: CallId(`${exec.callId}:nested`),
name: 'read',
arguments: { file_path: '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
},
}))
let shouldBlock = true
ctx.on('tools/post-execute', async (exec, _result, next) => {
const downstream = await next()
return exec.name === 'composite-read' && shouldBlock
? { 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({
callId: CallId('composite-first'), name: 'composite-read', arguments: {}, agent,
})
shouldBlock = false
const accepted = await ctx.tools.execute({
callId: CallId('composite-retry'), name: 'composite-read', arguments: {}, agent,
})
expect(blocked.isError).toBe(true)
expect(blocked.additionalContexts).toBeUndefined()
expect(accepted.isError).toBe(false)
expect(blocksText(workspaceContextOf(accepted)?.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('handles defensive tools/result observer branches without retaining staged state', async () => {
const ctx = new Context()
try {
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const agent = stubAgent('/')
const parent = Symbol('parent') as ToolExecutionToken
const plainResult = { callId: CallId('plain'), content: [], isError: false }
ctx.emit('tools/result', stubToolExecution({
callId: CallId('agentless-child'), name: 'read', arguments: {}, parent,
}), plainResult)
ctx.emit('tools/result', stubToolExecution({
callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent,
}), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] })
ctx.emit('tools/result', stubToolExecution({
callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent,
}), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] })
ctx.emit('tools/result', stubToolExecution({
callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent,
}), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] })
ctx.emit('tools/result', {
...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
token: parent,
}, plainResult)
expect(agent.session.deriveMessages()).toEqual([])
} finally {
await ctx.fiber.dispose()
}
})
it('ignores post-execute events that are not successful structured file touches', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -2201,6 +2506,39 @@ describe('dynamic nested workspace context injection', () => {
})
})
describe('workspace context pending state', () => {
it('rolls back only the exact current transition and releases empty session state', () => {
const agent = stubAgent('/')
const pending = new WeakMap<object, Map<string, PendingInstructionChange>>()
rollbackPendingInstructionChanges(agent, [{
action: 'set', scope: 'missing', path: 'missing/AGENTS.md', digest: 'none',
}], pending)
expect(commitPendingInstructionContexts(agent, [{
content: [], source: { kind: 'plugin', plugin: 'workspace-context' },
}], pending)).toEqual([])
const committed = commitPendingInstructionContexts(agent, [
workspaceChangeContext('first', 'one'),
workspaceChangeContext('second', 'two'),
], pending)
const [first, second] = committed
expect(first).toBeDefined()
expect(second).toBeDefined()
const [newer] = commitPendingInstructionContexts(agent, [workspaceChangeContext('first', 'newer')], pending)
rollbackPendingInstructionChanges(agent, [first!], pending)
rollbackPendingInstructionChanges(agent, [{
action: 'set', scope: 'unknown', path: 'unknown/AGENTS.md', digest: 'unknown',
}], pending)
rollbackPendingInstructionChanges(agent, [second!], pending)
expect(pending.get(agent.session)?.get('first')?.change).toEqual(newer)
rollbackPendingInstructionChanges(agent, [newer!], pending)
expect(pending.has(agent.session)).toBe(false)
})
})
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)