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

@@ -8,7 +8,7 @@ The baseline is composed once per agent-loop instance on `agent/session-prefix`.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. A new file is attached through the result's `additionalContexts`; a changed file or candidate switch appends a replacement; a missing final candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It calls `ctx.fs.lstat` before resolving a candidate, rejecting a final-component symlink instead of following repository-owned links across the trust boundary. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It calls `ctx.fs.lstat` before resolving a candidate, rejecting a final-component symlink instead of following repository-owned links across the trust boundary. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
## Prompt Shape
@@ -46,7 +46,7 @@ The core `context/message` envelope is disabled for these messages because the p
## State And Refresh
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context returned by `tools/post-execute` but not yet appended by the loop.
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
An unchanged path and SHA-1 content digest is not injected again. Resume works because visible metadata is persisted in the session log. Compaction re-arms a scope after its context event leaves the visible surface. A removal is a tombstone, so a later candidate reappearance is loaded again. Only changes actually rendered within the byte budget enter metadata and pending state; an omitted change remains eligible for a later touch.
@@ -59,19 +59,20 @@ export interface Config {
dshHome?: string
projectRootMarkers?: string[]
maxBytes: number
maxSourceBytes?: number
instructionFileCandidates?: string[]
}
```
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite byte budget disables both baseline and dynamic loading.
The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer.
## Budgeting And Cache
## Budgeting And Bounded Reads
Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`.
Each discovered candidate is read and identified by normalized absolute path, the provider's opaque version, and a SHA-1 content digest. Comparing the digest after reading prevents a same-version, same-size rewrite from returning stale cached text. Discovery carries the provider version into reading so one pass does not stat the same file twice. Cache identity is separate from loaded-state identity: persisted structured metadata, not cached prose, controls duplicate suppression.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored without falling through to a lower-priority same-directory candidate; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide prose cache: every reconciliation observes current content, computes its SHA-1 only after the bounded read, and relies on persisted structured metadata for duplicate suppression.
## Non-goals

View File

@@ -9,6 +9,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-paths'
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
const DEFAULT_MAX_SOURCE_BYTES = 1_048_576
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
/** User-facing workspace instruction loader configuration. */
@@ -19,6 +20,8 @@ export interface Config {
projectRootMarkers?: string[]
/** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
maxBytes: number
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
maxSourceBytes?: number
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
instructionFileCandidates?: string[]
}
@@ -27,6 +30,7 @@ export const Config: z<Config> = z.object({
dshHome: z.string(),
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
maxBytes: z.number().required(),
maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES),
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
})
@@ -40,6 +44,7 @@ export interface ResolvedDiscoveryConfig {
/** Normalized configuration used by discovery and reconciliation. */
export interface ResolvedConfig extends ResolvedDiscoveryConfig {
maxBytes: number
maxSourceBytes: number
}
/**
@@ -51,6 +56,7 @@ export function resolveConfig(config: Config): ResolvedConfig {
return {
...resolveDiscoveryConfig(config),
maxBytes: config.maxBytes,
maxSourceBytes: config.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES,
}
}

View File

@@ -1,5 +1,5 @@
/**
* Content identity for workspace instruction caching and duplicate suppression.
* Content identity for workspace instruction duplicate suppression.
*
* @module @deepseek-ai/dsh-workspace-context/digest
*/

View File

@@ -1,15 +1,15 @@
/**
* Instruction-file discovery, provider reads, and content-aware caching.
* Instruction-file discovery and bounded, abort-aware provider reads.
*
* @module @deepseek-ai/dsh-workspace-context/files
*/
import { lstat, readFile, stat } from 'node:fs/promises'
import { createReadStream } from 'node:fs'
import { lstat, stat } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { FileSystem, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { instructionContentSha1 } from './digest.ts'
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
/** An instruction candidate identified by absolute and model-facing paths. */
@@ -23,33 +23,22 @@ export interface LoadedInstructionFile extends InstructionFile {
content: string
}
interface FileSignature {
version: string
}
interface CachedContent extends FileSignature {
sha1: string
content: string
}
interface DiscoveredInstructionFile extends InstructionFile {
signature: FileSignature
target?: FsTarget
size?: number
}
/** Provider-version and SHA-1 keyed content cache shared across plugin hooks. */
export type InstructionContentCache = Map<string, CachedContent>
interface DiscoverOptions {
cwd: string
dshHome?: string
projectRootMarkers?: string[]
instructionFileCandidates?: string[]
signal?: AbortSignal
}
interface LoadOptions extends DiscoverOptions {
maxBytes: number
cache?: InstructionContentCache
maxSourceBytes?: number
}
/** Rendered baseline plus the files that survived byte budgeting. */
@@ -64,12 +53,19 @@ export type ScopeInstructionProbe =
| { kind: 'absent' }
| { kind: 'unavailable' }
async function nodeStatFile(path: string): Promise<FileSignature | undefined> {
function signalOptions(signal?: AbortSignal): { signal: AbortSignal } | undefined {
return signal === undefined ? undefined : { signal }
}
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<{ size: number } | undefined> {
try {
signal?.throwIfAborted()
const info = await lstat(path)
signal?.throwIfAborted()
if (!info.isFile()) return undefined
return { version: String(info.mtimeMs) }
return { size: info.size }
} catch {
signal?.throwIfAborted()
// Candidates can disappear while discovery is in progress.
return undefined
}
@@ -78,15 +74,17 @@ async function nodeStatFile(path: string): Promise<FileSignature | undefined> {
async function fsStatFile(
path: string,
fileSystem: FileSystem,
): Promise<DiscoveredInstructionFile['signature'] & { target: FsTarget } | undefined> {
signal?: AbortSignal,
): Promise<{ target: FsTarget; size?: number } | undefined> {
try {
const pathInfo = await fileSystem.lstat(path)
const pathInfo = await fileSystem.lstat(path, undefined, signal)
if (pathInfo?.type !== 'file') return undefined
const target = await fileSystem.resolve(path)
const info = await fileSystem.stat(target)
const target = await fileSystem.resolve(path, signalOptions(signal))
const info = await fileSystem.stat(target, signal)
if (info?.type !== 'file') return undefined
return { version: info.version, target }
return { target, ...info.size === undefined ? {} : { size: info.size } }
} catch {
signal?.throwIfAborted()
// Provider absence and discovery races are both non-fatal.
return undefined
}
@@ -95,23 +93,28 @@ async function fsStatFile(
async function statFile(
path: string,
fileSystem?: FileSystem,
): Promise<(DiscoveredInstructionFile['signature'] & { target?: FsTarget }) | undefined> {
return fileSystem === undefined ? nodeStatFile(path) : fsStatFile(path, fileSystem)
signal?: AbortSignal,
): Promise<{ target?: FsTarget; size?: number } | undefined> {
return fileSystem === undefined ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal)
}
async function existsAsMarker(path: string, fileSystem?: FileSystem): Promise<boolean> {
async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: AbortSignal): Promise<boolean> {
if (fileSystem !== undefined) {
try {
const target = await fileSystem.resolve(path)
return await fileSystem.stat(target) !== undefined
const target = await fileSystem.resolve(path, signalOptions(signal))
return await fileSystem.stat(target, signal) !== undefined
} catch {
signal?.throwIfAborted()
return false
}
}
try {
signal?.throwIfAborted()
await stat(path)
signal?.throwIfAborted()
return true
} catch {
signal?.throwIfAborted()
return false
}
}
@@ -121,17 +124,19 @@ async function existsAsMarker(path: string, fileSystem?: FileSystem): Promise<bo
* @param cwd - absolute session working directory where the walk begins.
* @param markers - child names that identify a project root.
* @param fileSystem - optional provider used instead of host filesystem probes.
* @param signal - cancellation for provider and host probes.
* @returns the discovered project root, or `cwd` when no marker exists.
*/
export async function findProjectRoot(
cwd: string,
markers: readonly string[],
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<string> {
let current = resolve(cwd)
for (;;) {
for (const marker of markers) {
if (await existsAsMarker(join(current, marker), fileSystem)) return current
if (await existsAsMarker(join(current, marker), fileSystem, signal)) return current
}
const parent = dirname(current)
if (parent === current) return resolve(cwd)
@@ -190,17 +195,16 @@ async function firstExistingInstructionFile(
root: string,
instructionFileCandidates: readonly string[],
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<DiscoveredInstructionFile | undefined> {
for (const candidate of instructionFileCandidates) {
const path = join(dir, candidate)
const fileSignature = await statFile(path, fileSystem)
if (fileSignature !== undefined) {
const { target, ...signature } = fileSignature
const fileInfo = await statFile(path, fileSystem, signal)
if (fileInfo !== undefined) {
return {
absolutePath: path,
displayPath: relativeDisplay(root, path),
signature,
...target === undefined ? {} : { target },
...fileInfo,
}
}
}
@@ -221,21 +225,19 @@ async function discoverInstructionFiles(
}
const userGlobal = join(config.dshHome, 'AGENTS.md')
const userGlobalSignature = await statFile(userGlobal, fileSystem)
if (userGlobalSignature !== undefined) {
const { target, ...signature } = userGlobalSignature
const userGlobalInfo = await statFile(userGlobal, fileSystem, options.signal)
if (userGlobalInfo !== undefined) {
addFile({
absolutePath: userGlobal,
displayPath: userGlobalDisplayPath(config.dshHome),
signature,
...target === undefined ? {} : { target },
...userGlobalInfo,
})
}
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
for (const dir of ancestorChain(projectRoot, cwd)) {
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem)
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem, options.signal)
if (file !== undefined) addFile(file)
}
return files
@@ -250,23 +252,35 @@ export async function discoverBaselineInstructionFiles(options: DiscoverOptions)
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
}
async function readCached(
async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterable<string> {
const stream = createReadStream(path, { encoding: 'utf8', signal })
for await (const chunk of stream) yield String(chunk)
}
async function readBounded(
file: DiscoveredInstructionFile,
cache: InstructionContentCache,
maxSourceBytes: number,
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<string | undefined> {
const path = file.absolutePath
const { signature } = file
signal?.throwIfAborted()
if (file.size !== undefined && file.size > maxSourceBytes) return undefined
try {
const content = fileSystem === undefined || file.target === undefined
? await readFile(path, 'utf8')
: await fileSystem.readText(file.target)
const sha1 = instructionContentSha1(content)
const cached = cache.get(path)
if (cached !== undefined && cached.version === signature.version && cached.sha1 === sha1) return cached.content
cache.set(path, { ...signature, sha1, content })
return content
const chunks = fileSystem === undefined || file.target === undefined
? nodeTextChunks(file.absolutePath, signal)
: await fileSystem.streamText(file.target, signal)
const parts: string[] = []
let bytes = 0
for await (const chunk of chunks) {
signal?.throwIfAborted()
bytes += Buffer.byteLength(chunk, 'utf8')
if (bytes > maxSourceBytes) return undefined
parts.push(chunk)
}
signal?.throwIfAborted()
return parts.join('')
} catch {
signal?.throwIfAborted()
// A file may disappear or become unreadable after its metadata probe.
return undefined
}
@@ -274,7 +288,7 @@ async function readCached(
/**
* Discover, read, and render the baseline instruction chain.
* @param options - discovery, byte-budget, and optional cache configuration.
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
* @param fileSystem - optional provider used instead of host filesystem reads.
* @returns rendered baseline context, or undefined when nothing can be loaded.
*/
@@ -287,7 +301,7 @@ export async function loadBaselineInstructions(
/**
* Load a baseline together with the files retained after rendering.
* @param options - discovery, byte-budget, and optional cache configuration.
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
* @param fileSystem - optional provider used instead of host filesystem reads.
* @returns rendered context and retained files, or undefined when empty or disabled.
*/
@@ -297,11 +311,11 @@ export async function loadBaselineInstructionSet(
): Promise<RenderedInstructionSet | undefined> {
const config = resolveConfig(options)
if (config.maxBytes <= 0 || !Number.isFinite(config.maxBytes)) return undefined
const cache = options.cache ?? new Map<string, CachedContent>()
if (config.maxSourceBytes <= 0 || !Number.isFinite(config.maxSourceBytes)) return undefined
const discovered = await discoverInstructionFiles(options, fileSystem)
const loaded: LoadedInstructionFile[] = []
for (const file of discovered) {
const content = await readCached(file, cache, fileSystem)
const content = await readBounded(file, config.maxSourceBytes, fileSystem, options.signal)
if (content !== undefined) loaded.push({ absolutePath: file.absolutePath, displayPath: file.displayPath, content })
}
if (loaded.length === 0) return undefined
@@ -315,16 +329,16 @@ export async function loadBaselineInstructionSet(
* @param scope - `user-global`, `.`, or a project-relative directory.
* @param projectRoot - project root used to resolve and display project scopes.
* @param resolved - normalized plugin configuration.
* @param cache - shared content cache.
* @param fileSystem - provider used for no-follow probing and reading.
* @param signal - cancellation for provider probes and streaming.
* @returns present content, confirmed absence, or temporary unavailability.
*/
export async function loadScopeInstruction(
scope: string,
projectRoot: string,
resolved: ResolvedConfig,
cache: InstructionContentCache,
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<ScopeInstructionProbe> {
const dir = scope === 'user-global'
? resolved.dshHome
@@ -334,27 +348,29 @@ export async function loadScopeInstruction(
const absolutePath = join(dir, candidate)
let pathInfo: FsPathInfo | undefined
try {
pathInfo = await fileSystem.lstat(absolutePath)
pathInfo = await fileSystem.lstat(absolutePath, undefined, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (pathInfo === undefined || pathInfo.type !== 'file') continue
let target: FsTarget
let info: FsInfo | undefined
try {
target = await fileSystem.resolve(absolutePath)
info = await fileSystem.stat(target)
target = await fileSystem.resolve(absolutePath, signalOptions(signal))
info = await fileSystem.stat(target, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (info?.type !== 'file') return { kind: 'unavailable' }
const discovered: DiscoveredInstructionFile = {
absolutePath,
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
signature: { version: info.version },
target,
...info.size === undefined ? {} : { size: info.size },
}
const content = await readCached(discovered, cache, fileSystem)
const content = await readBounded(discovered, resolved.maxSourceBytes, fileSystem, signal)
if (content === undefined) return { kind: 'unavailable' }
return { kind: 'present', file: { absolutePath, displayPath: discovered.displayPath, content } }
}

View File

@@ -12,17 +12,16 @@
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { PostToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
import {
loadBaselineInstructionSet,
type InstructionContentCache,
} from './files.ts'
import { loadBaselineInstructionSet } from './files.ts'
import {
baselineInstructionChanges,
commitPendingInstructionContexts,
dynamicInstructionContext,
name,
reconcileInstructionContext,
rollbackPendingInstructionChanges,
workspaceContextMessage,
type PendingInstructionChange,
} from './state.ts'
@@ -34,7 +33,6 @@ export {
loadBaselineInstructions,
} from './files.ts'
export type {
InstructionContentCache,
InstructionFile,
LoadedInstructionFile,
} from './files.ts'
@@ -43,11 +41,11 @@ export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = resolveConfig(config)
const cache: InstructionContentCache = new Map()
const pendingNestedChanges = new WeakMap<object, Map<string, PendingInstructionChange>>()
const baselineInstructionStates = new WeakMap<object, Map<string, WorkspaceInstructionChange>>()
const pendingByParent = new Map<ToolExecutionToken, { agent: Agent; changes: WorkspaceInstructionChange[] }>()
ctx.on('agent/session-prefix', async (agent: Agent, _prefix, _signal, next): Promise<Message[]> => {
ctx.on('agent/session-prefix', async (agent: Agent, _prefix, signal, next): Promise<Message[]> => {
const rest = await next()
if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return rest
const fileSystem = ctx.get('fs')
@@ -59,19 +57,19 @@ export function apply(ctx: Context, config: Config): void {
dshHome: resolved.dshHome,
projectRootMarkers: resolved.projectRootMarkers,
maxBytes: resolved.maxBytes,
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
cache,
signal,
}, fileSystem)
baselineInstructionStates.set(agent.session, baselineInstructionChanges(instructions?.included ?? []))
const update = await reconcileInstructionContext(
agent,
resolved,
cache,
pendingNestedChanges,
baselineInstructionStates,
fileSystem,
{ includeBaselineScopes: false },
{ includeBaselineScopes: false, signal },
)
if (update !== undefined) {
agent.inject(update.content, {
@@ -104,7 +102,6 @@ export function apply(ctx: Context, config: Config): void {
exec,
result,
resolved,
cache,
pendingNestedChanges,
baselineInstructionStates,
fileSystem,
@@ -116,4 +113,28 @@ export function apply(ctx: Context, config: Config): void {
additionalContexts: [context, ...downstream.additionalContexts ?? []],
}
})
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
if (exec.parent !== undefined) {
if (exec.agent === undefined) return
// Child contexts participate in duplicate suppression within one composite
// run, but remain provisional until the parent reaches its final policy.
const changes = commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
if (changes.length === 0) return
const staged = pendingByParent.get(exec.parent)
if (staged === undefined) pendingByParent.set(exec.parent, { agent: exec.agent, changes })
else staged.changes.push(...changes)
return
}
// The parent result is authoritative: remove every provisional child change,
// then commit only contexts that survived outer post-execute policy.
const staged = pendingByParent.get(exec.token)
if (staged !== undefined) {
pendingByParent.delete(exec.token)
rollbackPendingInstructionChanges(staged.agent, staged.changes, pendingNestedChanges)
}
if (exec.agent === undefined) return
commitPendingInstructionContexts(exec.agent, result.additionalContexts, pendingNestedChanges)
})
}

View File

@@ -17,7 +17,6 @@ import {
findProjectRoot,
loadScopeInstruction,
relativeDisplay,
type InstructionContentCache,
type LoadedInstructionFile,
} from './files.ts'
import {
@@ -157,6 +156,56 @@ function pendingChangesFor(
return pending
}
/**
* Commit only workspace contexts that survived the complete tool pipeline.
* The observe-only `tools/result` notification calls this before the loop can
* append the returned contexts, closing that short pending window without
* trusting an intermediate post-execute decision.
* @param agent - session that will receive the final result contexts.
* @param contexts - immutable contexts on the authoritative top-level result.
* @param pendingBySession - per-session pending transition maps.
* @returns transitions committed into the short pending window.
*/
export function commitPendingInstructionContexts(
agent: Agent,
contexts: readonly HookContext[] | undefined,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): WorkspaceInstructionChange[] {
const committed: WorkspaceInstructionChange[] = []
for (const context of contexts ?? []) {
if (!isWorkspaceContextSource(context.source)) continue
const changes = workspaceInstructionChanges(context.meta)
if (changes.length === 0) continue
const pending = pendingChangesFor(agent.session, pendingBySession)
for (const change of changes) {
pending.set(change.scope, { change, afterSeq: agent.session.seq })
committed.push(change)
}
}
return committed
}
/**
* Roll back parent-token state when an enclosing tool result discards deferred
* contexts. A newer transition for the same scope is left intact.
* @param agent - session whose pending state was staged.
* @param changes - exact staged transitions to remove when still current.
* @param pendingBySession - per-session pending transition maps.
*/
export function rollbackPendingInstructionChanges(
agent: Agent,
changes: readonly WorkspaceInstructionChange[],
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
): void {
const pending = pendingBySession.get(agent.session)
if (pending === undefined) return
for (const change of changes) {
const current = pending.get(change.scope)
if (current !== undefined && sameInstructionChange(current.change, change)) pending.delete(change.scope)
}
if (pending.size === 0) pendingBySession.delete(agent.session)
}
function relativeScope(projectRoot: string, dir: string): string {
const scope = relativeDisplay(projectRoot, dir)
return scope.length === 0 ? '.' : scope
@@ -166,7 +215,6 @@ function relativeScope(projectRoot: string, dir: string): string {
* Compare visible/pending state with provider-visible files and render transitions.
* @param agent - session owner whose visible surface supplies durable state.
* @param resolved - normalized plugin configuration.
* @param cache - shared provider-version and content-digest cache.
* @param pendingBySession - short pending window before returned context is logged.
* @param baselineBySession - frozen baseline comparison state per session.
* @param fileSystem - provider used for current file probes.
@@ -176,11 +224,10 @@ function relativeScope(projectRoot: string, dir: string): string {
export async function reconcileInstructionContext(
agent: Agent,
resolved: ResolvedConfig,
cache: InstructionContentCache,
pendingBySession: WeakMap<object, Map<string, PendingInstructionChange>>,
baselineBySession: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
fileSystem: FileSystem,
options: { touchedPath?: string; includeBaselineScopes: boolean },
options: { touchedPath?: string; includeBaselineScopes: boolean; signal?: AbortSignal },
): Promise<WorkspaceHookContext | undefined> {
const session = agent.session
const pending = pendingChangesFor(session, pendingBySession)
@@ -189,7 +236,7 @@ export async function reconcileInstructionContext(
for (const [scope, change] of visible) effective.set(scope, change)
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = session.header.cwd ?? process.cwd()
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem)
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const scopes = new Set<string>()
if (options.includeBaselineScopes) {
scopes.add('user-global')
@@ -204,7 +251,7 @@ export async function reconcileInstructionContext(
const unavailable = new Set<string>()
const seenAbsolutePaths = new Set<string>()
for (const scope of scopes) {
const probe = await loadScopeInstruction(scope, projectRoot, resolved, cache, fileSystem)
const probe = await loadScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
if (probe.kind === 'unavailable') {
unavailable.add(scope)
continue
@@ -250,7 +297,6 @@ export async function reconcileInstructionContext(
if (items.length === 0) return undefined
const rendered = renderInstructionChanges(items, resolved.maxBytes)
if (rendered.text.length === 0 || rendered.changes.length === 0) return undefined
for (const change of rendered.changes) pending.set(change.scope, { change, afterSeq: session.seq })
return workspaceContextHook(rendered.text, rendered.changes)
}
@@ -260,7 +306,6 @@ export async function reconcileInstructionContext(
* @param exec - completed tool execution descriptor.
* @param result - original tool result before post-execute decisions.
* @param resolved - normalized plugin configuration.
* @param cache - shared provider-version and content-digest cache.
* @param pendingNestedChanges - per-session pending transition maps.
* @param baselineInstructionStates - retained baseline comparison state.
* @param fileSystem - provider used for current file probes.
@@ -271,7 +316,6 @@ export async function dynamicInstructionContext(
exec: ToolExecution,
result: ToolExecutionResult,
resolved: ResolvedConfig,
cache: InstructionContentCache,
pendingNestedChanges: WeakMap<object, Map<string, PendingInstructionChange>>,
baselineInstructionStates: WeakMap<object, Map<string, WorkspaceInstructionChange>>,
fileSystem: FileSystem,
@@ -280,7 +324,11 @@ export async function dynamicInstructionContext(
const touchedPath = filePathFromExecution(exec)
if (touchedPath === undefined) return undefined
return reconcileInstructionContext(
agent, resolved, cache, pendingNestedChanges, baselineInstructionStates, fileSystem,
{ touchedPath, includeBaselineScopes: baselineInstructionStates.has(agent.session) },
agent, resolved, pendingNestedChanges, baselineInstructionStates, fileSystem,
{
touchedPath,
includeBaselineScopes: baselineInstructionStates.has(agent.session),
...exec.signal === undefined ? {} : { signal: exec.signal },
},
)
}

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)