feat(workspace-context): load all instruction candidates, dedup by trimmed content, follow symlinks
Squashes feat/instruction-load-all-dedup and feat/allow-instruction-symlink.
This commit is contained in:
@@ -4,11 +4,11 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, the first existing base candidate and then the first existing local-overlay candidate. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
|
||||
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
|
||||
|
||||
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.
|
||||
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. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier 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. Once `lstat` identifies the winning regular-file candidate, a later resolve/stat failure makes that scope temporarily unavailable instead of falling through to a lower-priority name. 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.
|
||||
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 resolves each candidate and stats the result, so a final-component symlink is followed to its target: a link to a regular file loads that target's content, while a missing path or a non-file target (including a link to a directory) is a confirmed absence. A resolve or stat exception instead marks that candidate's scope temporarily unavailable. 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
|
||||
|
||||
@@ -40,15 +40,15 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
|
||||
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
|
||||
|
||||
The plugin owns the complete `<system-reminder>` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
|
||||
|
||||
## 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 present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. 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.
|
||||
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, 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. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. 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. A per-session, per-scope metadata cache stores only `{ path, version, digest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
|
||||
|
||||
The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix.
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
`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. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and loads the first existing local overlay *in addition to* the base file of the same directory (rendered after it); an empty list disables the overlay. Candidate entries in both lists 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 every existing candidate loads, and candidates whose content matches an earlier one after trimming surrounding whitespace are dropped, so with the defaults an `AGENTS.md` and a `CLAUDE.md` that share content render once (as `AGENTS.md`) while genuinely distinct siblings both apply. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and loads its existing overlays alongside the base files of the same directory (rendered after them) under the same per-directory dedup; an empty list disables the overlay. Candidate entries in both lists 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` with no local overlay; both candidate lists only control 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.
|
||||
|
||||
@@ -73,7 +73,7 @@ The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both
|
||||
|
||||
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`.
|
||||
|
||||
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 cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
|
||||
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; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -137,7 +137,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A changed file produces `Updated instructions from: <path>` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below.
|
||||
A changed file produces `Updated instructions from: <path>` plus its replacement content. A candidate that disappears or becomes a per-directory duplicate of an earlier candidate produces the removal notice below.
|
||||
|
||||
##### Removal notice
|
||||
|
||||
@@ -162,4 +162,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
|
||||
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix.
|
||||
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration.
|
||||
- **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it.
|
||||
- **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories.
|
||||
- **Instruction content is bounded, not summarized** — over-budget broad files are omitted and the most-specific file may be truncated; the plugin never asks a model to compress instruction prose.
|
||||
|
||||
@@ -23,9 +23,15 @@ export interface Config {
|
||||
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. */
|
||||
/**
|
||||
* Ordered same-directory project candidates; every existing file loads, with
|
||||
* per-directory trimmed-content duplicates collapsed to the earliest candidate.
|
||||
*/
|
||||
instructionFileCandidates?: string[]
|
||||
/** Ordered same-directory local-overlay candidates loaded in addition to the base file per scope; empty disables the overlay. */
|
||||
/**
|
||||
* Ordered same-directory local-overlay candidates loaded after the base files
|
||||
* under the same per-directory trimmed-content dedup; empty disables the overlay.
|
||||
*/
|
||||
localInstructionFileCandidates?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,15 @@ import { createHash } from 'node:crypto'
|
||||
export function instructionContentSha1(content: string): string {
|
||||
return createHash('sha1').update(content).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the whitespace-insensitive identity used for per-directory duplicate
|
||||
* suppression. Leading and trailing whitespace is trimmed before hashing so a
|
||||
* symlinked or byte-copied sibling that differs only by surrounding whitespace
|
||||
* still collapses to a single rendered file.
|
||||
* @param content - exact UTF-8 instruction text.
|
||||
* @returns SHA-1 digest of the trimmed content.
|
||||
*/
|
||||
export function trimmedInstructionDigest(content: string): string {
|
||||
return instructionContentSha1(content.trim())
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
*/
|
||||
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { lstat, stat } from 'node:fs/promises'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FileSystem, FsInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||
import { decodeScopeKey, renderWorkspaceContext, type InstructionTier, type RenderedWorkspaceContext } from './render.ts'
|
||||
import { trimmedInstructionDigest } from './digest.ts'
|
||||
import { decodeScopeKey, renderWorkspaceContext, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts'
|
||||
|
||||
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||
export interface InstructionFile {
|
||||
@@ -24,18 +25,15 @@ export interface LoadedInstructionFile extends InstructionFile {
|
||||
content: string
|
||||
/** Provider freshness token when the file was loaded through `ctx.fs`. */
|
||||
version?: FsVersion
|
||||
/** Base file or additive local overlay; absent is treated as base. */
|
||||
tier?: InstructionTier
|
||||
}
|
||||
|
||||
interface DiscoveredInstructionFile extends InstructionFile {
|
||||
target?: FsTarget
|
||||
size?: number
|
||||
version?: FsVersion
|
||||
tier: InstructionTier
|
||||
}
|
||||
|
||||
/** Provider metadata for a winning scope candidate before its content is read. */
|
||||
/** Provider metadata for a probed scope candidate before its content is read. */
|
||||
export interface ProbedInstructionFile extends InstructionFile {
|
||||
target: FsTarget
|
||||
version: FsVersion
|
||||
@@ -90,7 +88,9 @@ function isMissingPathError(error: unknown): boolean {
|
||||
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<StatFileProbe> {
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
const info = await lstat(path)
|
||||
// stat (not lstat) follows a final-component symlink so a link to a regular
|
||||
// file loads; a broken link surfaces as ENOENT and is treated as absent below.
|
||||
const info = await stat(path)
|
||||
signal?.throwIfAborted()
|
||||
if (!info.isFile()) return { kind: 'absent' }
|
||||
return { kind: 'present', info: { size: info.size } }
|
||||
@@ -105,25 +105,15 @@ async function fsStatFile(
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StatFileProbe> {
|
||||
// TODO(instruction-symlink-race): replace this lstat -> resolve -> read
|
||||
// protocol, including probeScopeInstruction below, with a provider-owned
|
||||
// atomic no-follow read so the final component cannot change after validation.
|
||||
let pathInfo: FsPathInfo | undefined
|
||||
try {
|
||||
pathInfo = await fileSystem.lstat(path, undefined, signal)
|
||||
signal?.throwIfAborted()
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (pathInfo?.type !== 'file') return { kind: 'absent' }
|
||||
|
||||
// resolve() follows a final-component symlink to its target's stable identity;
|
||||
// stat then classifies that target. A link to a regular file loads, while a
|
||||
// missing path or non-file target (including a link to a directory) is absent.
|
||||
try {
|
||||
const target = await fileSystem.resolve(path, signalOptions(signal))
|
||||
signal?.throwIfAborted()
|
||||
const info = await fileSystem.stat(target, signal)
|
||||
signal?.throwIfAborted()
|
||||
if (info?.type !== 'file') return { kind: 'unavailable' }
|
||||
if (info?.type !== 'file') return { kind: 'absent' }
|
||||
return {
|
||||
kind: 'present',
|
||||
info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } },
|
||||
@@ -236,35 +226,32 @@ export function relativeDisplay(root: string, path: string): string {
|
||||
return relative(root, path)
|
||||
}
|
||||
|
||||
async function firstExistingInstructionFile(
|
||||
async function allExistingInstructionFiles(
|
||||
dir: string,
|
||||
root: string,
|
||||
instructionFileCandidates: readonly string[],
|
||||
tier: InstructionTier,
|
||||
fileSystem?: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DiscoveredInstructionFile | undefined> {
|
||||
): Promise<DiscoveredInstructionFile[]> {
|
||||
const found: DiscoveredInstructionFile[] = []
|
||||
for (const candidate of instructionFileCandidates) {
|
||||
const path = join(dir, candidate)
|
||||
const probe = await statFile(path, fileSystem, signal)
|
||||
switch (probe.kind) {
|
||||
case 'present':
|
||||
return {
|
||||
absolutePath: path,
|
||||
displayPath: relativeDisplay(root, path),
|
||||
tier,
|
||||
...probe.info,
|
||||
}
|
||||
case 'absent':
|
||||
found.push({ absolutePath: path, displayPath: relativeDisplay(root, path), ...probe.info })
|
||||
continue
|
||||
// A missing candidate is skipped; a transient provider failure skips only
|
||||
// that candidate so the remaining independent candidates still load.
|
||||
case 'absent':
|
||||
case 'unavailable':
|
||||
return undefined
|
||||
continue
|
||||
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
|
||||
default:
|
||||
return assertNever(probe, 'StatFileProbe')
|
||||
assertNever(probe, 'StatFileProbe')
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
return found
|
||||
}
|
||||
|
||||
async function discoverInstructionFiles(
|
||||
@@ -280,14 +267,13 @@ async function discoverInstructionFiles(
|
||||
files.push(file)
|
||||
}
|
||||
|
||||
const userGlobal = join(config.dshHome, 'AGENTS.md')
|
||||
const userGlobal = join(config.dshHome, USER_GLOBAL_FILE)
|
||||
const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal)
|
||||
switch (userGlobalProbe.kind) {
|
||||
case 'present':
|
||||
addFile({
|
||||
absolutePath: userGlobal,
|
||||
displayPath: userGlobalDisplayPath(config.dshHome),
|
||||
tier: 'base',
|
||||
...userGlobalProbe.info,
|
||||
})
|
||||
break
|
||||
@@ -302,11 +288,10 @@ async function discoverInstructionFiles(
|
||||
const cwd = resolve(options.cwd)
|
||||
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) {
|
||||
const base = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, 'base', fileSystem, options.signal)
|
||||
if (base !== undefined) addFile(base)
|
||||
if (config.localInstructionFileCandidates.length > 0) {
|
||||
const local = await firstExistingInstructionFile(dir, projectRoot, config.localInstructionFileCandidates, 'local', fileSystem, options.signal)
|
||||
if (local !== undefined) addFile(local)
|
||||
for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) {
|
||||
for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) {
|
||||
addFile(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
@@ -314,8 +299,10 @@ async function discoverInstructionFiles(
|
||||
|
||||
/**
|
||||
* Discover host-visible user-global and root-to-cwd instruction candidates.
|
||||
* All present candidates in each directory are returned; trimmed-content
|
||||
* duplicates are collapsed later, once content is read.
|
||||
* @param options - cwd, home, root marker, and candidate configuration.
|
||||
* @returns de-duplicated instruction paths in model precedence order.
|
||||
* @returns path-deduplicated instruction candidates in model precedence order.
|
||||
*/
|
||||
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
|
||||
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
|
||||
@@ -358,6 +345,33 @@ async function readBounded(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop later candidates whose trimmed content duplicates an earlier sibling in
|
||||
* the same directory. Different directories never collapse even when identical;
|
||||
* within one directory the earliest candidate in discovery order is kept and its
|
||||
* original bytes are rendered. A candidate that symlinks a sibling resolves to
|
||||
* the same content and collapses here like any byte-identical real file.
|
||||
* @param files - loaded files in discovery order.
|
||||
* @returns the retained files in the same order.
|
||||
*/
|
||||
export function dedupInstructionFilesByDirectory(files: LoadedInstructionFile[]): LoadedInstructionFile[] {
|
||||
const keptDigestsByDir = new Map<string, Set<string>>()
|
||||
const kept: LoadedInstructionFile[] = []
|
||||
for (const file of files) {
|
||||
const dir = dirname(file.displayPath)
|
||||
let digests = keptDigestsByDir.get(dir)
|
||||
if (digests === undefined) {
|
||||
digests = new Set()
|
||||
keptDigestsByDir.set(dir, digests)
|
||||
}
|
||||
const digest = trimmedInstructionDigest(file.content)
|
||||
if (digests.has(digest)) continue
|
||||
digests.add(digest)
|
||||
kept.push(file)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover, read, and render the baseline instruction chain.
|
||||
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
|
||||
@@ -393,23 +407,23 @@ export async function loadBaselineInstructionSet(
|
||||
absolutePath: file.absolutePath,
|
||||
displayPath: file.displayPath,
|
||||
content,
|
||||
tier: file.tier,
|
||||
...file.version === undefined ? {} : { version: file.version },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (loaded.length === 0) return undefined
|
||||
const rendered = renderWorkspaceContext(loaded, { maxBytes: config.maxBytes })
|
||||
const deduped = dedupInstructionFilesByDirectory(loaded)
|
||||
if (deduped.length === 0) return undefined
|
||||
const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes })
|
||||
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
|
||||
return { rendered, included: loaded.filter(file => !omitted.has(file.absolutePath)) }
|
||||
return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the current first-winning instruction candidate for one logical scope.
|
||||
* @param scope - `user-global`, or a {@link scopeKey} for a project directory's base or local tier.
|
||||
* Probe the current provider metadata for one per-candidate instruction scope.
|
||||
* @param scope - a {@link candidateScopeKey} identifying a directory and candidate file.
|
||||
* @param projectRoot - project root used to resolve and display project scopes.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param fileSystem - provider used for no-follow probing.
|
||||
* @param fileSystem - provider used to resolve and stat scope candidates.
|
||||
* @param signal - cancellation for provider probes.
|
||||
* @returns present metadata, confirmed absence, or temporary unavailability.
|
||||
*/
|
||||
@@ -420,43 +434,32 @@ export async function probeScopeInstruction(
|
||||
fileSystem: FileSystem,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ScopeInstructionProbe> {
|
||||
const { directory, tier } = decodeScopeKey(scope)
|
||||
const dir = directory === 'user-global'
|
||||
const { directory, candidateName } = decodeScopeKey(scope)
|
||||
const dir = directory === USER_GLOBAL_DIRECTORY
|
||||
? resolved.dshHome
|
||||
: directory === '.' ? projectRoot : join(projectRoot, directory)
|
||||
const candidates = directory === 'user-global'
|
||||
? ['AGENTS.md']
|
||||
: tier === 'local' ? resolved.localInstructionFileCandidates : resolved.instructionFileCandidates
|
||||
for (const candidate of candidates) {
|
||||
const absolutePath = join(dir, candidate)
|
||||
let pathInfo: FsPathInfo | undefined
|
||||
try {
|
||||
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, signalOptions(signal))
|
||||
info = await fileSystem.stat(target, signal)
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (info?.type !== 'file') return { kind: 'unavailable' }
|
||||
const file: ProbedInstructionFile = {
|
||||
absolutePath,
|
||||
displayPath: directory === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||
target,
|
||||
version: info.version,
|
||||
...info.size === undefined ? {} : { size: info.size },
|
||||
}
|
||||
return { kind: 'present', file }
|
||||
const absolutePath = join(dir, candidateName)
|
||||
// resolve() follows a final-component symlink; stat then classifies the target.
|
||||
// A non-file target (missing, or a link to a directory) is a confirmed absence;
|
||||
// only a provider exception is reported as unavailable.
|
||||
let target: FsTarget
|
||||
let info: FsInfo | undefined
|
||||
try {
|
||||
target = await fileSystem.resolve(absolutePath, signalOptions(signal))
|
||||
info = await fileSystem.stat(target, signal)
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
return { kind: 'absent' }
|
||||
if (info?.type !== 'file') return { kind: 'absent' }
|
||||
const file: ProbedInstructionFile = {
|
||||
absolutePath,
|
||||
displayPath: directory === USER_GLOBAL_DIRECTORY ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||
target,
|
||||
version: info.version,
|
||||
...info.size === undefined ? {} : { size: info.size },
|
||||
}
|
||||
return { kind: 'present', file }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-workspace-context/render
|
||||
*/
|
||||
|
||||
import { dirname } from 'node:path'
|
||||
import { basename, dirname } from 'node:path'
|
||||
import type { InstructionFile, LoadedInstructionFile } from './files.ts'
|
||||
|
||||
const SYSTEM_REMINDER_OPEN = '<system-reminder>'
|
||||
@@ -33,7 +33,6 @@ export interface WorkspaceInstructionChange {
|
||||
action: 'set' | 'replace' | 'remove'
|
||||
scope: string
|
||||
path: string
|
||||
previousPath?: string
|
||||
digest?: string
|
||||
}
|
||||
|
||||
@@ -62,8 +61,8 @@ function truncateUtf8(value: string, maxBytes: number): string {
|
||||
|
||||
function escapeInstructionContent(content: string): string {
|
||||
// TODO(instruction-frame-paths): apply the same delimiter neutralization to
|
||||
// every interpolated path, scope, and previous path; repository-controlled
|
||||
// names can otherwise close the plugin-owned system-reminder frame.
|
||||
// every interpolated path and scope; repository-controlled names can
|
||||
// otherwise close the plugin-owned system-reminder frame.
|
||||
return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>')
|
||||
}
|
||||
|
||||
@@ -71,43 +70,63 @@ function sectionText(file: LoadedInstructionFile): string {
|
||||
return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}`
|
||||
}
|
||||
|
||||
/** Directory component that identifies the single user-global instruction scope. */
|
||||
export const USER_GLOBAL_DIRECTORY = 'user-global'
|
||||
|
||||
/**
|
||||
* File name of the single user-global instruction file under `$DSH_HOME`.
|
||||
* Discovery (`$DSH_HOME/<name>`) and reconciliation (the user-global scope key's
|
||||
* candidate component) both key on this name, so it lives in one place: were the
|
||||
* two to disagree, the user-global instruction would load but never reconcile.
|
||||
*/
|
||||
export const USER_GLOBAL_FILE = 'AGENTS.md'
|
||||
|
||||
/**
|
||||
* Derive the logical instruction scope from a model-facing path.
|
||||
* @param displayPath - project-relative or user-global instruction path.
|
||||
* @returns `user-global`, `.`, or the containing project-relative directory.
|
||||
*/
|
||||
export function scopeForDisplayPath(displayPath: string): string {
|
||||
if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return 'user-global'
|
||||
if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return USER_GLOBAL_DIRECTORY
|
||||
return dirname(displayPath)
|
||||
}
|
||||
|
||||
/** Instruction tier: the native base file or the additive local overlay. */
|
||||
export type InstructionTier = 'base' | 'local'
|
||||
|
||||
const LOCAL_SCOPE_SUFFIX = '\u0000local'
|
||||
const SCOPE_SEPARATOR = '\u0000'
|
||||
|
||||
/**
|
||||
* Compose the reconciliation key for a directory scope and instruction tier.
|
||||
* The base tier keeps the human-readable directory; the local overlay appends a
|
||||
* NUL-delimited marker that no directory path can contain, so a directory's base
|
||||
* and local files never collide in the scope-keyed state maps.
|
||||
* Compose the reconciliation key for one instruction candidate file.
|
||||
* Each loaded candidate is tracked independently, so the key pairs the logical
|
||||
* directory with the exact candidate file name behind a NUL separator that no
|
||||
* directory path or file name can contain. Distinct candidates in one directory
|
||||
* (`AGENTS.md` vs `CLAUDE.md`, a base file vs its `.local` overlay) therefore
|
||||
* never collide in the scope-keyed state maps.
|
||||
* @param directory - `user-global`, `.`, or a project-relative directory.
|
||||
* @param tier - base file or additive local overlay.
|
||||
* @returns the collision-free logical scope key.
|
||||
* @param candidateName - instruction file name within that directory.
|
||||
* @returns the per-candidate logical scope key.
|
||||
*/
|
||||
export function scopeKey(directory: string, tier: InstructionTier): string {
|
||||
return tier === 'local' ? `${directory}${LOCAL_SCOPE_SUFFIX}` : directory
|
||||
export function candidateScopeKey(directory: string, candidateName: string): string {
|
||||
return `${directory}${SCOPE_SEPARATOR}${candidateName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the directory and tier that {@link scopeKey} encoded.
|
||||
* @param scope - a base or local scope key.
|
||||
* @returns the directory scope and its instruction tier.
|
||||
* Derive the per-candidate scope key for a loaded instruction file.
|
||||
* @param displayPath - project-relative or user-global instruction path.
|
||||
* @returns the scope key pairing the file's directory with its name.
|
||||
*/
|
||||
export function decodeScopeKey(scope: string): { directory: string; tier: InstructionTier } {
|
||||
return scope.endsWith(LOCAL_SCOPE_SUFFIX)
|
||||
? { directory: scope.slice(0, -LOCAL_SCOPE_SUFFIX.length), tier: 'local' }
|
||||
: { directory: scope, tier: 'base' }
|
||||
export function instructionScopeKey(displayPath: string): string {
|
||||
return candidateScopeKey(scopeForDisplayPath(displayPath), basename(displayPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the directory and candidate name that {@link candidateScopeKey} encoded.
|
||||
* @param scope - a per-candidate scope key.
|
||||
* @returns the directory scope and the candidate file name within it.
|
||||
*/
|
||||
export function decodeScopeKey(scope: string): { directory: string; candidateName: string } {
|
||||
const separator = scope.indexOf(SCOPE_SEPARATOR)
|
||||
/* v8 ignore next -- every scope key is produced by candidateScopeKey, which always inserts the separator. */
|
||||
if (separator < 0) return { directory: scope, candidateName: '' }
|
||||
return { directory: scope.slice(0, separator), candidateName: scope.slice(separator + 1) }
|
||||
}
|
||||
|
||||
function additionalSectionText(file: LoadedInstructionFile): string {
|
||||
@@ -129,13 +148,10 @@ function changedSectionText(item: ChangeRenderItem): string {
|
||||
if (change.action === 'remove') {
|
||||
return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.`
|
||||
}
|
||||
const description = change.previousPath === undefined
|
||||
? 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.'
|
||||
: `The instructions previously loaded from \`${change.previousPath}\` no longer apply. Use the following content for \`${scopeForDisplayPath(change.path)}\` instead.`
|
||||
return [
|
||||
`Updated instructions from: ${change.path}`,
|
||||
'',
|
||||
description,
|
||||
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
|
||||
'',
|
||||
escapeInstructionContent(file.content),
|
||||
].join('\n')
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import { instructionContentSha1 } from './digest.ts'
|
||||
import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
|
||||
import {
|
||||
ancestorChain,
|
||||
descendantDirsBetween,
|
||||
@@ -21,9 +21,12 @@ import {
|
||||
type LoadedInstructionFile,
|
||||
} from './files.ts'
|
||||
import {
|
||||
candidateScopeKey,
|
||||
decodeScopeKey,
|
||||
instructionScopeKey,
|
||||
renderInstructionChanges,
|
||||
scopeForDisplayPath,
|
||||
scopeKey,
|
||||
USER_GLOBAL_DIRECTORY,
|
||||
USER_GLOBAL_FILE,
|
||||
type ChangeRenderItem,
|
||||
type WorkspaceInstructionChange,
|
||||
} from './render.ts'
|
||||
@@ -45,6 +48,11 @@ export interface InstructionVersionState {
|
||||
path: string
|
||||
version: FsVersion
|
||||
digest: string
|
||||
/**
|
||||
* Trimmed-content identity ({@link trimmedInstructionDigest}) used to suppress
|
||||
* per-directory duplicates on the metadata fast path without re-reading a sibling.
|
||||
*/
|
||||
trimmedDigest: string
|
||||
}
|
||||
|
||||
/** Session-isolated fast-path state keyed by logical instruction scope. */
|
||||
@@ -72,7 +80,6 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[
|
||||
action: change.action,
|
||||
scope: change.scope,
|
||||
path: change.path,
|
||||
...change.previousPath !== undefined ? { previousPath: change.previousPath } : {},
|
||||
...change.digest !== undefined ? { digest: change.digest } : {},
|
||||
}))
|
||||
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
|
||||
@@ -113,13 +120,11 @@ function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInst
|
||||
if (!isRecord(value)) continue
|
||||
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
|
||||
if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
|
||||
if (value.previousPath !== undefined && typeof value.previousPath !== 'string') continue
|
||||
if (value.digest !== undefined && typeof value.digest !== 'string') continue
|
||||
changes.push({
|
||||
action: value.action,
|
||||
scope: value.scope,
|
||||
path: value.path,
|
||||
...value.previousPath !== undefined ? { previousPath: value.previousPath } : {},
|
||||
...value.digest !== undefined ? { digest: value.digest } : {},
|
||||
})
|
||||
}
|
||||
@@ -130,7 +135,6 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru
|
||||
return a.action === b.action
|
||||
&& a.scope === b.scope
|
||||
&& a.path === b.path
|
||||
&& a.previousPath === b.previousPath
|
||||
&& a.digest === b.digest
|
||||
}
|
||||
|
||||
@@ -170,13 +174,18 @@ export function baselineInstructionState(files: LoadedInstructionFile[]): {
|
||||
const digest = instructionContentSha1(file.content)
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action: 'set',
|
||||
scope: scopeKey(scopeForDisplayPath(file.displayPath), file.tier ?? 'base'),
|
||||
scope: instructionScopeKey(file.displayPath),
|
||||
path: file.displayPath,
|
||||
digest,
|
||||
}
|
||||
changes.set(change.scope, change)
|
||||
if (file.version !== undefined) {
|
||||
versions.set(change.scope, { path: file.displayPath, version: file.version, digest })
|
||||
versions.set(change.scope, {
|
||||
path: file.displayPath,
|
||||
version: file.version,
|
||||
digest,
|
||||
trimmedDigest: trimmedInstructionDigest(file.content),
|
||||
})
|
||||
}
|
||||
}
|
||||
return { changes, versions }
|
||||
@@ -392,40 +401,57 @@ export async function reconcileInstructionContext(
|
||||
// recomputing it after marker edits reinterprets the existing relative scope keys.
|
||||
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
|
||||
const scopes = new Set<string>()
|
||||
const localEnabled = resolved.localInstructionFileCandidates.length > 0
|
||||
const addDirScopes = (directory: string): void => {
|
||||
for (const candidate of resolved.instructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
|
||||
for (const candidate of resolved.localInstructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
|
||||
}
|
||||
const addProjectScopes = (dir: string): void => {
|
||||
const scope = relativeScope(projectRoot, dir)
|
||||
scopes.add(scope)
|
||||
if (localEnabled) scopes.add(scopeKey(scope, 'local'))
|
||||
addDirScopes(relativeScope(projectRoot, dir))
|
||||
}
|
||||
if (options.includeBaselineScopes) {
|
||||
scopes.add('user-global')
|
||||
scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir)
|
||||
}
|
||||
for (const scope of effective.keys()) scopes.add(scope)
|
||||
for (const scope of effective.keys()) {
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
else addDirScopes(directory)
|
||||
}
|
||||
if (options.touchedPath !== undefined) {
|
||||
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir)
|
||||
}
|
||||
|
||||
const versions = versionStatesFor(session, versionCache)
|
||||
const seenAbsolutePaths = new Set<string>()
|
||||
// Per-directory trimmed-content identities kept so far this pass, iterated in
|
||||
// candidate order (base before local); a later sibling matching an earlier one
|
||||
// is a duplicate and is dropped or removed rather than rendered twice.
|
||||
const keptTrimmedByDir = new Map<string, Set<string>>()
|
||||
const registerKeptTrimmed = (directory: string, digest: string): boolean => {
|
||||
let digests = keptTrimmedByDir.get(directory)
|
||||
if (digests === undefined) {
|
||||
digests = new Set()
|
||||
keptTrimmedByDir.set(directory, digests)
|
||||
}
|
||||
if (digests.has(digest)) return true
|
||||
digests.add(digest)
|
||||
return false
|
||||
}
|
||||
const items: ChangeRenderItem[] = []
|
||||
const versionUpdates: InstructionVersionUpdate[] = []
|
||||
const pushRemoval = (scope: string, path: string): void => {
|
||||
const change: WorkspaceInstructionChange = { action: 'remove', scope, path }
|
||||
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
|
||||
versionUpdates.push({ change })
|
||||
}
|
||||
for (const scope of scopes) {
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
const previous = effective.get(scope)
|
||||
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') continue
|
||||
if (probe.kind === 'absent') {
|
||||
if (previous === undefined || previous.action === 'remove') {
|
||||
versions.delete(scope)
|
||||
continue
|
||||
}
|
||||
const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path }
|
||||
items.push({
|
||||
change,
|
||||
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
|
||||
})
|
||||
versionUpdates.push({ change })
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
const { file: probedFile } = probe
|
||||
@@ -440,29 +466,39 @@ export async function reconcileInstructionContext(
|
||||
&& previous.action !== 'remove'
|
||||
&& previous.path === cached.path
|
||||
&& previous.digest === cached.digest
|
||||
) continue
|
||||
) {
|
||||
// Unchanged and previously rendered: keep it, but an earlier sibling that
|
||||
// now matches its trimmed content makes this the duplicate to remove.
|
||||
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
|
||||
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
|
||||
if (file === undefined) continue
|
||||
const currentDigest = instructionContentSha1(file.content)
|
||||
const trimmedDigest = trimmedInstructionDigest(file.content)
|
||||
if (registerKeptTrimmed(directory, trimmedDigest)) {
|
||||
// A distinct file whose trimmed content already appeared earlier in this
|
||||
// directory: drop it, removing any copy that was previously rendered.
|
||||
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
|
||||
else versions.delete(scope)
|
||||
continue
|
||||
}
|
||||
const nextVersion: InstructionVersionState = {
|
||||
path: file.displayPath,
|
||||
version: probedFile.version,
|
||||
digest: currentDigest,
|
||||
trimmedDigest,
|
||||
}
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
|
||||
versions.set(scope, nextVersion)
|
||||
continue
|
||||
}
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
|
||||
? previous.path
|
||||
: undefined
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
...previousPath === undefined ? {} : { previousPath },
|
||||
digest: currentDigest,
|
||||
}
|
||||
items.push({ change, file })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
@@ -7,9 +7,8 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { agentEvents, type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
@@ -24,12 +23,7 @@ import type {
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
PostToolDecision,
|
||||
ToolExecution,
|
||||
ToolExecutionResult,
|
||||
ToolExecutionToken,
|
||||
} 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,
|
||||
@@ -44,8 +38,12 @@ import {
|
||||
type InstructionVersionCache,
|
||||
type PendingInstructionChange,
|
||||
} from '../src/state.ts'
|
||||
import { candidateScopeKey } from '../src/render.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/** Per-candidate reconciliation scope key: directory paired with the file name. */
|
||||
const sk = (directory: string, candidateName: string): string => candidateScopeKey(directory, candidateName)
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
async function tempRepo(): Promise<string> {
|
||||
@@ -59,9 +57,7 @@ async function write(path: string, content: string): Promise<void> {
|
||||
|
||||
class RecordingFileSystem extends FileSystem {
|
||||
entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>()
|
||||
lstatTypes = new Map<string, FsPathInfo['type']>()
|
||||
throwOnStat = new Set<string>()
|
||||
throwOnRead = new Set<string>()
|
||||
omitSizes = new Set<string>()
|
||||
readTargets: string[] = []
|
||||
readTextTargets: string[] = []
|
||||
@@ -70,7 +66,7 @@ class RecordingFileSystem extends FileSystem {
|
||||
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 = resolve(opts?.cwd ?? '/', path)
|
||||
const absolute = join(opts?.cwd ?? '/', path)
|
||||
return { targetKey: FsTargetKey(absolute), displayPath: absolute }
|
||||
}
|
||||
|
||||
@@ -92,8 +88,6 @@ class RecordingFileSystem extends FileSystem {
|
||||
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, signal)
|
||||
if (info === undefined) return undefined
|
||||
return {
|
||||
@@ -114,7 +108,6 @@ class RecordingFileSystem extends FileSystem {
|
||||
if (signal !== undefined) this.signals.push(signal)
|
||||
signal?.throwIfAborted()
|
||||
this.readTargets.push(target.targetKey)
|
||||
if (this.throwOnRead.has(target.targetKey)) throw new Error(`read failed: ${target.displayPath}`)
|
||||
const content = this.entries.get(target.targetKey)?.content ?? ''
|
||||
return (async function* () {
|
||||
const midpoint = Math.ceil(content.length / 2)
|
||||
@@ -235,31 +228,14 @@ const composedPrefixes = new WeakMap<object, Message[]>()
|
||||
|
||||
async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
const prefix = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, AbortSignal.timeout(1000),
|
||||
const prefix = await ctx.waterfall(
|
||||
'agent/session-prefix', agent, empty, AbortSignal.timeout(1000),
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
composedPrefixes.set(agent, prefix)
|
||||
return prefix
|
||||
}
|
||||
|
||||
function toolEventCarrier(ctx: Context, exec: ToolExecution) {
|
||||
return scopeTarget(ctx.get('tools') ?? ctx as unknown as ToolRegistry, exec.agent)
|
||||
}
|
||||
|
||||
function postExecute(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
result: Readonly<ToolExecutionResult>,
|
||||
next: () => Promise<PostToolDecision>,
|
||||
): Promise<PostToolDecision> {
|
||||
return ctx.waterfall(toolEventCarrier(ctx, exec), 'tools/post-execute', exec, result, next)
|
||||
}
|
||||
|
||||
function emitToolResult(ctx: Context, exec: ToolExecution, result: Readonly<ToolExecutionResult>): void {
|
||||
ctx.emit(toolEventCarrier(ctx, exec), 'tools/result', exec, result)
|
||||
}
|
||||
|
||||
function derivedText(agent: Agent): string {
|
||||
return blocksText(composedPrefixes.get(agent)?.[0]?.content)
|
||||
}
|
||||
@@ -284,7 +260,7 @@ describe('workspace context instruction discovery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('loads user-global first, then root-to-cwd workspace instructions using the default candidate order', async () => {
|
||||
it('loads user-global first, then every root-to-cwd candidate in precedence order', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -292,7 +268,7 @@ describe('workspace context instruction discovery', () => {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(home, 'AGENTS.md'), 'global rules')
|
||||
await write(join(root, 'AGENTS.md'), 'root agents')
|
||||
await write(join(root, 'CLAUDE.md'), 'root claude ignored')
|
||||
await write(join(root, 'CLAUDE.md'), 'root claude')
|
||||
await write(join(root, 'packages/CLAUDE.md'), 'package claude')
|
||||
await write(join(cwd, 'AGENTS.md'), 'app agents')
|
||||
|
||||
@@ -301,10 +277,11 @@ describe('workspace context instruction discovery', () => {
|
||||
expect(files.map(file => file.displayPath)).toEqual([
|
||||
'$DSH_HOME/AGENTS.md',
|
||||
'AGENTS.md',
|
||||
join('packages', 'CLAUDE.md'),
|
||||
join('packages', 'app', 'AGENTS.md'),
|
||||
'CLAUDE.md',
|
||||
'packages/CLAUDE.md',
|
||||
'packages/app/AGENTS.md',
|
||||
])
|
||||
expect(files.map(file => file.absolutePath)).not.toContain(join(root, 'CLAUDE.md'))
|
||||
expect(files.map(file => file.absolutePath)).toContain(join(root, 'CLAUDE.md'))
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -406,44 +383,41 @@ describe('workspace context instruction discovery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('skips a provider file whose read fails after a successful metadata probe', async () => {
|
||||
it('skips a file that becomes unreadable after discovery without failing the request', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
try {
|
||||
const cwd = join(root, 'pkg')
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await mkdir(cwd, { recursive: true })
|
||||
const leaf = join(cwd, 'AGENTS.md')
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(leaf, { type: 'file', content: 'secret-ish rule' })
|
||||
fs.throwOnRead.add(leaf)
|
||||
await write(leaf, 'secret-ish rule')
|
||||
await chmod(leaf, 0)
|
||||
|
||||
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }, fs)
|
||||
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
|
||||
|
||||
expect(loaded).toBeUndefined()
|
||||
expect(fs.readTargets).toEqual([leaf])
|
||||
await chmod(leaf, 0o600)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects symlinked instruction files instead of following repository-controlled links', async () => {
|
||||
it('follows a symlinked instruction file to its target content', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const outside = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(outside, 'secret.txt'), 'outside secret')
|
||||
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
|
||||
await write(join(outside, 'shared.md'), 'shared instruction body')
|
||||
await symlink(join(outside, 'shared.md'), join(root, 'AGENTS.md'))
|
||||
|
||||
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home })
|
||||
const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
|
||||
|
||||
expect(files).toEqual([])
|
||||
expect(loaded).toBeUndefined()
|
||||
expect(files.map(file => file.displayPath)).toContain('AGENTS.md')
|
||||
expect(loaded?.text).toContain('shared instruction body')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -451,21 +425,21 @@ describe('workspace context instruction discovery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects symlinked instruction files through ctx.fs instead of following repository-controlled links', async () => {
|
||||
it('follows a symlinked instruction file through ctx.fs to its target content', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const outside = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(outside, 'secret.txt'), 'outside secret')
|
||||
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
|
||||
await write(join(outside, 'shared.md'), 'shared provider instruction body')
|
||||
await symlink(join(outside, 'shared.md'), join(root, 'AGENTS.md'))
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expectNoDerivedMessages(agent)
|
||||
expect(derivedText(agent)).toContain('shared provider instruction body')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -511,7 +485,7 @@ describe('workspace context instruction discovery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the configured instruction candidate order without hard-coding AGENTS.md priority', async () => {
|
||||
it('loads every configured instruction candidate in configured order without hard-coding AGENTS.md priority', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -526,7 +500,7 @@ describe('workspace context instruction discovery', () => {
|
||||
instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'],
|
||||
})
|
||||
|
||||
expect(files.map(file => file.displayPath)).toEqual(['CLAUDE.local.md'])
|
||||
expect(files.map(file => file.displayPath)).toEqual(['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -556,6 +530,12 @@ describe('workspace context instruction discovery', () => {
|
||||
|
||||
it('defaults dshHome and uses cwd itself as root when no project marker exists', async () => {
|
||||
const root = await tempRepo()
|
||||
const emptyHome = await tempRepo()
|
||||
// Isolate the default-home fallback: blank DSH_HOME is treated as unset, and
|
||||
// HOME points at an empty dir so the default ~/.dsh holds no global scope.
|
||||
// Symlinks are now followed, so a real ~/.dsh/AGENTS.md would otherwise leak in.
|
||||
vi.stubEnv('DSH_HOME', '')
|
||||
vi.stubEnv('HOME', emptyHome)
|
||||
try {
|
||||
const cwd = join(root, 'child')
|
||||
await mkdir(cwd, { recursive: true })
|
||||
@@ -567,7 +547,9 @@ describe('workspace context instruction discovery', () => {
|
||||
expect(files.map(file => file.displayPath)).toEqual(['AGENTS.md'])
|
||||
expect(files.map(file => file.absolutePath)).toEqual([join(cwd, 'AGENTS.md')])
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(emptyHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -596,7 +578,6 @@ describe('workspace context instruction discovery', () => {
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('node:os', () => ({ homedir: () => home }))
|
||||
vi.stubEnv('DSH_HOME', undefined)
|
||||
const isolated = await import('@deepseek-ai/dsh-workspace-context')
|
||||
const files = await isolated.discoverBaselineInstructionFiles({ cwd: root })
|
||||
|
||||
@@ -604,7 +585,6 @@ describe('workspace context instruction discovery', () => {
|
||||
} finally {
|
||||
vi.doUnmock('node:os')
|
||||
vi.resetModules()
|
||||
vi.unstubAllEnvs()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
@@ -871,7 +851,7 @@ describe('workspace context request injection', () => {
|
||||
try {
|
||||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||||
|
||||
const decision = await postExecute(ctx, stubToolExecution({
|
||||
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('no-fs-post-execute'),
|
||||
name: 'read',
|
||||
@@ -920,7 +900,7 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
|
||||
// A later PostToolUse-style policy blocks this otherwise-successful read.
|
||||
const blocked = await postExecute(ctx, exec, result, async () => ({
|
||||
const blocked = await ctx.waterfall('tools/post-execute', exec, result, async () => ({
|
||||
kind: 'block' as const,
|
||||
feedback: [{ type: 'text' as const, text: 'blocked by policy' }],
|
||||
}))
|
||||
@@ -934,7 +914,7 @@ describe('workspace context request injection', () => {
|
||||
// The same read, when the downstream accepts, DOES surface the nested
|
||||
// instructions — proving the block branch above is what suppressed them,
|
||||
// and that the block did not consume the pending nested change.
|
||||
const accepted = await postExecute(ctx, exec, result, async () => ({
|
||||
const accepted = await ctx.waterfall('tools/post-execute', exec, result, async () => ({
|
||||
kind: 'accept' as const,
|
||||
}))
|
||||
expect(accepted.kind).toBe('accept')
|
||||
@@ -1009,7 +989,7 @@ describe('workspace context request injection', () => {
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(derivedText(agent)).toContain('omitted AGENTS.md')
|
||||
expect(derivedText(agent)).toContain(`Instructions from: ${join('pkg', 'AGENTS.md')}\n\npackage rule`)
|
||||
expect(derivedText(agent)).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -1059,7 +1039,7 @@ describe('workspace context request injection', () => {
|
||||
})
|
||||
|
||||
expect(workspaceContextOf(result)?.meta).toMatchObject({
|
||||
changes: [{ action: 'replace', scope: '.', path: 'AGENTS.md' }],
|
||||
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md')
|
||||
expect(blocksText(workspaceContextOf(result)?.content)).toContain('new root rule with more detail')
|
||||
@@ -1088,7 +1068,7 @@ describe('workspace context request injection', () => {
|
||||
})
|
||||
|
||||
expect(workspaceContextOf(result)?.meta).toMatchObject({
|
||||
changes: [{ action: 'remove', scope: '.', path: 'AGENTS.md' }],
|
||||
changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md')
|
||||
} finally {
|
||||
@@ -1120,6 +1100,32 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates trimmed-identical sibling candidates in one directory and renders the earliest original bytes', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'shared repo rule')
|
||||
await write(join(root, 'CLAUDE.md'), ' shared repo rule\n\n')
|
||||
await write(join(root, 'file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
const text = derivedText(agent)
|
||||
expect(text.match(/shared repo rule/g)).toHaveLength(1)
|
||||
expect(text).toContain('Instructions from: AGENTS.md')
|
||||
expect(text).not.toContain('Instructions from: CLAUDE.md')
|
||||
// The kept candidate's original bytes are rendered, not the whitespace-padded duplicate.
|
||||
expect(text).not.toContain(' shared repo rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not expose state markers when a tiny budget reduces the baseline contribution', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
@@ -1268,9 +1274,8 @@ describe('workspace context request injection', () => {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel prefix')
|
||||
const empty: Message[] = []
|
||||
const agent = stubAgent(root)
|
||||
const pending = agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, controller.signal,
|
||||
const pending = ctx.waterfall(
|
||||
'agent/session-prefix', stubAgent(root), empty, controller.signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
|
||||
@@ -1337,30 +1342,6 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('skips provider-visible instruction candidates when ctx.fs stat disagrees after no-follow preflight', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'node fs rule')
|
||||
const ctx = new Context()
|
||||
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: 'directory' })
|
||||
fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file')
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expectNoDerivedMessages(agent)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('loads instruction files when ctx.fs omits the metadata size', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
@@ -1407,7 +1388,7 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fall through to a lower-priority candidate when the winning provider file becomes unavailable', async () => {
|
||||
it('skips a candidate whose provider probe fails while still loading its available sibling', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -1415,16 +1396,16 @@ describe('workspace context request injection', () => {
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.lstatTypes.set(join(root, 'AGENTS.md'), 'file')
|
||||
fs.throwOnStat.add(join(root, 'AGENTS.md'))
|
||||
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'must not bypass AGENTS failure' })
|
||||
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expectNoDerivedMessages(agent)
|
||||
expect(fs.readTargets).not.toContain(join(root, 'CLAUDE.md'))
|
||||
expect(derivedText(agent)).toContain('claude sibling rule')
|
||||
expect(fs.readTargets).toContain(join(root, 'CLAUDE.md'))
|
||||
expect(fs.readTargets).not.toContain(join(root, 'AGENTS.md'))
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -1498,7 +1479,7 @@ describe('workspace context request injection', () => {
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nroot schema default rule')
|
||||
expect(derivedText(agent)).toContain(`Instructions from: ${join('child', 'AGENTS.md')}\n\nchild schema default rule`)
|
||||
expect(derivedText(agent)).toContain('Instructions from: child/AGENTS.md\n\nchild schema default rule')
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -1629,9 +1610,9 @@ describe('workspace context request injection', () => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
lstat: async (path: string) => {
|
||||
stat: async (path: string) => {
|
||||
observedStats.set(path, (observedStats.get(path) ?? 0) + 1)
|
||||
return actual.lstat(path)
|
||||
return actual.stat(path)
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1649,22 +1630,22 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not bypass an unavailable host AGENTS.md with a lower-priority candidate', async () => {
|
||||
it('skips an unavailable host candidate but still loads its available sibling', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'CLAUDE.md'), 'must not bypass unavailable AGENTS')
|
||||
await write(join(root, 'CLAUDE.md'), 'claude host sibling rule')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
lstat: async (path: string) => {
|
||||
stat: async (path: string) => {
|
||||
if (path === join(root, 'AGENTS.md')) {
|
||||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' })
|
||||
}
|
||||
return actual.lstat(path)
|
||||
return actual.stat(path)
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1672,7 +1653,7 @@ describe('workspace context request injection', () => {
|
||||
|
||||
const rendered = await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
|
||||
|
||||
expect(rendered).toBeUndefined()
|
||||
expect(rendered?.text).toContain('claude host sibling rule')
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
@@ -1718,7 +1699,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
description: 'Abort the current test step.',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
agent.cancel({ kind: 'user' })
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('test abort')
|
||||
return [{ type: 'text', text: 'aborted' }]
|
||||
},
|
||||
}))
|
||||
@@ -1751,7 +1732,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
content: 'root rule',
|
||||
}])
|
||||
|
||||
const change = state.changes.get('.')
|
||||
const change = state.changes.get(sk('.', 'AGENTS.md'))
|
||||
expect(change).toMatchObject({
|
||||
action: 'set',
|
||||
path: 'AGENTS.md',
|
||||
@@ -1781,7 +1762,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const pending = postExecute(ctx, exec, {
|
||||
const pending = ctx.waterfall('tools/post-execute', exec, {
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
}, () => Promise.resolve({ kind: 'accept' as const }))
|
||||
@@ -1822,8 +1803,8 @@ describe('dynamic nested workspace context injection', () => {
|
||||
version: 1,
|
||||
changes: [{
|
||||
action: 'set',
|
||||
scope: 'pkg',
|
||||
path: join('pkg', 'AGENTS.md'),
|
||||
scope: sk('pkg', 'AGENTS.md'),
|
||||
path: 'pkg/AGENTS.md',
|
||||
}],
|
||||
})
|
||||
const meta = workspaceContextOf(result)?.meta
|
||||
@@ -1837,7 +1818,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const text = blocksText(workspaceContextOf(result)?.content)
|
||||
expect(text).toBe([
|
||||
'<system-reminder>',
|
||||
`Additional instructions from: ${join('pkg', 'AGENTS.md')}`,
|
||||
'Additional instructions from: pkg/AGENTS.md',
|
||||
'',
|
||||
'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.',
|
||||
'',
|
||||
@@ -1852,7 +1833,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses configured instruction candidates for nested discovery', async () => {
|
||||
it('loads every configured instruction candidate present in a nested scope', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -1876,9 +1857,11 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
|
||||
const text = blocksText(workspaceContextOf(result)?.content)
|
||||
expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`)
|
||||
expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md')
|
||||
expect(text).toContain('local package rule')
|
||||
expect(text).not.toContain('native package rule')
|
||||
expect(text).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(text).toContain('native package rule')
|
||||
expect(text.indexOf('pkg/CLAUDE.local.md')).toBeLessThan(text.indexOf('pkg/AGENTS.md'))
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -1898,6 +1881,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-nested-overlay'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -1939,6 +1923,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-nested-overlay-disabled'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
@@ -2126,11 +2111,11 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
expect(workspaceContextOf(changed)?.meta).toMatchObject({
|
||||
kind: 'workspace-instructions',
|
||||
changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
|
||||
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(changed)?.content)).toBe([
|
||||
'<system-reminder>',
|
||||
`Updated instructions from: ${join('pkg', 'AGENTS.md')}`,
|
||||
'Updated instructions from: pkg/AGENTS.md',
|
||||
'',
|
||||
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
|
||||
'',
|
||||
@@ -2143,13 +2128,13 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces an AGENTS candidate with the configured fallback in the same scope', async () => {
|
||||
it('reconciles distinct sibling candidates as independent scopes', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'native package rule')
|
||||
await write(join(root, 'pkg/CLAUDE.md'), 'fallback package rule')
|
||||
await write(join(root, 'pkg/CLAUDE.md'), 'sibling package rule')
|
||||
await write(join(root, 'pkg/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
@@ -2157,29 +2142,129 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
callId: CallId('read-both-siblings'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
const firstText = blocksText(workspaceContextOf(first)?.content)
|
||||
expect(firstText).toContain('native package rule')
|
||||
expect(firstText).toContain('sibling package rule')
|
||||
appendAdditionalContexts(agent, first)
|
||||
await rm(join(root, 'pkg/AGENTS.md'))
|
||||
const changed = await ctx.tools.execute({
|
||||
const removed = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, changed)
|
||||
const unchanged = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-logged-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
callId: CallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
expect(workspaceContextOf(changed)?.meta).toMatchObject({
|
||||
changes: [{
|
||||
action: 'replace', scope: 'pkg', path: join('pkg', 'CLAUDE.md'), previousPath: join('pkg', 'AGENTS.md'),
|
||||
}],
|
||||
// Removing one candidate only removes its own scope; the sibling scope is untouched.
|
||||
expect(workspaceContextOf(removed)?.meta).toMatchObject({
|
||||
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`Updated instructions from: ${join('pkg', 'CLAUDE.md')}`)
|
||||
expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`The instructions previously loaded from \`${join('pkg', 'AGENTS.md')}\` no longer apply. Use the following content for \`pkg\` instead.`)
|
||||
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('fallback package rule')
|
||||
expect(unchanged.additionalContexts).toBeUndefined()
|
||||
expect(blocksText(workspaceContextOf(removed)?.content)).toContain('Instructions removed: pkg/AGENTS.md')
|
||||
expect(blocksText(workspaceContextOf(removed)?.content)).not.toContain('sibling package rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a newly discovered sibling whose content duplicates an earlier candidate in the scope', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested rule')
|
||||
await write(join(root, 'pkg/CLAUDE.md'), 'nested rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent,
|
||||
})
|
||||
|
||||
expect(workspaceContextOf(result)?.meta).toMatchObject({
|
||||
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
|
||||
})
|
||||
const text = blocksText(workspaceContextOf(result)?.content)
|
||||
expect(text.match(/nested rule/g)).toHaveLength(1)
|
||||
expect(text).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(text).not.toContain('pkg/CLAUDE.md')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('removes a previously rendered sibling once its content becomes a duplicate of an earlier candidate', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'canonical nested rule')
|
||||
await write(join(root, 'pkg/CLAUDE.md'), 'divergent nested rule')
|
||||
await write(join(root, 'pkg/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
const firstText = blocksText(workspaceContextOf(first)?.content)
|
||||
expect(firstText).toContain('canonical nested rule')
|
||||
expect(firstText).toContain('divergent nested rule')
|
||||
appendAdditionalContexts(agent, first)
|
||||
await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule')
|
||||
const converged = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
expect(workspaceContextOf(converged)?.meta).toMatchObject({
|
||||
changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: 'pkg/CLAUDE.md' }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(converged)?.content)).toContain('Instructions removed: pkg/CLAUDE.md')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('removes an unchanged sibling when an earlier candidate changes to match its content', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'primary nested rule')
|
||||
await write(join(root, 'pkg/CLAUDE.md'), 'secondary nested rule')
|
||||
await write(join(root, 'pkg/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
// Only the earlier candidate changes; the sibling stays byte-identical but now duplicates it.
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'secondary nested rule')
|
||||
const converged = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
expect(workspaceContextOf(converged)?.meta).toMatchObject({
|
||||
changes: [
|
||||
{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' },
|
||||
{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: 'pkg/CLAUDE.md' },
|
||||
],
|
||||
})
|
||||
const text = blocksText(workspaceContextOf(converged)?.content)
|
||||
expect(text).toContain('Instructions removed: pkg/CLAUDE.md')
|
||||
expect(text).toContain('Updated instructions from: pkg/AGENTS.md')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -2211,11 +2296,11 @@ describe('dynamic nested workspace context injection', () => {
|
||||
expect(workspaceContextOf(removed)?.meta).toEqual({
|
||||
kind: 'workspace-instructions',
|
||||
version: 1,
|
||||
changes: [{ action: 'remove', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
|
||||
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
|
||||
'<system-reminder>',
|
||||
`Instructions removed: ${join('pkg', 'AGENTS.md')}`,
|
||||
'Instructions removed: pkg/AGENTS.md',
|
||||
'',
|
||||
'The previously loaded instructions from this file no longer apply.',
|
||||
'</system-reminder>',
|
||||
@@ -2226,6 +2311,45 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes a previously loaded instruction file once it resolves to a directory through a symlink', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'package rule')
|
||||
await write(join(root, 'pkg/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
appendAdditionalContexts(agent, first)
|
||||
expect(blocksText(workspaceContextOf(first)?.content)).toContain('package rule')
|
||||
|
||||
// The candidate now resolves through a symlink to a directory. A non-file
|
||||
// target is a confirmed absence (not unavailable), so the loaded scope is
|
||||
// removed; an unavailable classification would emit no change at all.
|
||||
await rm(join(root, 'pkg/AGENTS.md'))
|
||||
await mkdir(join(root, 'pkg/elsewhere'), { recursive: true })
|
||||
await symlink(join(root, 'pkg/elsewhere'), join(root, 'pkg/AGENTS.md'))
|
||||
const removed = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
})
|
||||
|
||||
expect(workspaceContextOf(removed)?.meta).toMatchObject({
|
||||
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(removed)?.content)).toContain('Instructions removed: pkg/AGENTS.md')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('loads a candidate again after a logged removal tombstone', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
@@ -2256,9 +2380,9 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
|
||||
expect(workspaceContextOf(restored)?.meta).toMatchObject({
|
||||
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
|
||||
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
|
||||
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -2363,7 +2487,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
const update = resumed.session.events.findLast(event => event.type === 'context/message')
|
||||
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
|
||||
changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
|
||||
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
|
||||
})
|
||||
expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
|
||||
} finally {
|
||||
@@ -2491,8 +2615,8 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
|
||||
const firstText = blocksText(workspaceContextOf(first)?.content)
|
||||
expect(firstText).toContain(`omitted ${join('pkg', 'AGENTS.md')}`)
|
||||
expect(firstText).not.toContain(`## ${join('pkg', 'AGENTS.md')}`)
|
||||
expect(firstText).toContain('omitted pkg/AGENTS.md')
|
||||
expect(firstText).not.toContain('## pkg/AGENTS.md')
|
||||
expect(firstText).toContain('subtree rule')
|
||||
expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule')
|
||||
} finally {
|
||||
@@ -2524,7 +2648,6 @@ describe('dynamic nested workspace context injection', () => {
|
||||
null,
|
||||
{ action: 'unknown', scope: 'pkg', path: 'pkg/AGENTS.md' },
|
||||
{ action: 'set', scope: 'pkg', path: 42 },
|
||||
{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md', previousPath: 42 },
|
||||
{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 42 },
|
||||
],
|
||||
},
|
||||
@@ -2594,7 +2717,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('treats provider failures and type disagreement after lstat as unavailable, not removed', async () => {
|
||||
it('treats a reconciliation provider failure as unavailable and a resolved non-file as absent', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
@@ -2602,7 +2725,6 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.lstatTypes.set(join(root, 'pkg/AGENTS.md'), 'file')
|
||||
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
@@ -2612,13 +2734,13 @@ describe('dynamic nested workspace context injection', () => {
|
||||
isError: false,
|
||||
}
|
||||
|
||||
const failedStat = await postExecute(ctx, stubToolExecution({
|
||||
const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
}), result, async () => ({ kind: 'accept' as const }))
|
||||
fs.throwOnStat.clear()
|
||||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' })
|
||||
const mismatchedStat = await postExecute(ctx, stubToolExecution({
|
||||
const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
}), result, async () => ({ kind: 'accept' as const }))
|
||||
@@ -2635,19 +2757,14 @@ describe('dynamic nested workspace context injection', () => {
|
||||
it('skips unreadable nested instruction files without attaching empty context', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
const nested = join(root, 'pkg/AGENTS.md')
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(nested, { type: 'file', content: 'nested package rule' })
|
||||
fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' })
|
||||
fs.throwOnRead.add(nested)
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
await write(nested, 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
await chmod(nested, 0)
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
@@ -2659,9 +2776,8 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.additionalContexts).toBeUndefined()
|
||||
expect(fs.readTargets).toContain(nested)
|
||||
await chmod(nested, 0o600)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
@@ -2698,7 +2814,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
|
||||
expect(workspaceContextOf(result)?.meta).toMatchObject({
|
||||
kind: 'workspace-instructions',
|
||||
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
|
||||
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
|
||||
expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context')
|
||||
@@ -2816,12 +2932,13 @@ describe('dynamic nested workspace context injection', () => {
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
const nested = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`${exec.callId}:nested`),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/deep/file.txt' },
|
||||
...exec.agent === undefined ? {} : { agent: exec.agent },
|
||||
parent: exec.token,
|
||||
signal: exec.signal,
|
||||
...exec.signal === undefined ? {} : { signal: exec.signal },
|
||||
})
|
||||
for (const context of nested.additionalContexts ?? []) exec.deferContext(context)
|
||||
return nested.content
|
||||
@@ -2866,23 +2983,23 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const parent = Symbol('parent') as ToolExecutionToken
|
||||
const plainResult = { callId: CallId('plain'), content: [], isError: false }
|
||||
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('agentless-child'), name: 'read', arguments: {}, parent,
|
||||
}), plainResult)
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] })
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] })
|
||||
emitToolResult(ctx, stubToolExecution({
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] })
|
||||
emitToolResult(ctx, {
|
||||
ctx.emit('tools/result', {
|
||||
...stubToolExecution({ signal: testToolSignal, callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
|
||||
token: parent,
|
||||
}, plainResult)
|
||||
@@ -2918,7 +3035,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const decision = await postExecute(ctx, stubToolExecution({
|
||||
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`),
|
||||
name: item.name,
|
||||
@@ -3042,7 +3159,7 @@ describe('workspace context pending state', () => {
|
||||
const [change] = commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
|
||||
expect(change).toBeDefined()
|
||||
versions.set(agent.session, new Map([['pkg', {
|
||||
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one',
|
||||
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
|
||||
}]]))
|
||||
|
||||
const unrelated = agent.session.append('context/message', {
|
||||
@@ -3079,7 +3196,7 @@ describe('workspace context pending state', () => {
|
||||
agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
|
||||
versions.set(agent.session, new Map([['pkg', {
|
||||
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one',
|
||||
path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
|
||||
}]]))
|
||||
|
||||
const ended = agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
|
||||
Reference in New Issue
Block a user