Fix workspace context review findings
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user