fix(workspace-context): require explicit byte budgets
This commit is contained in:
@@ -40,11 +40,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-core'
|
||||
// { agents?, persona?, toolOrder?, tools?, skills?, workspaceContext? } — the schema intersects the owner schemas,
|
||||
// { agents?, persona?, toolOrder?, tools?, skills?, workspaceContext } — workspaceContext requires { maxBytes } or false;
|
||||
// so validation and defaulting can never drift from the owners.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; and `workspaceContext` to `dsh-workspace-context` (`false` disables automatic instruction-file loading). Workspace instructions are registered before the skill catalog so their session-prefix message renders first. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; and the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it). Workspace instructions are registered before the skill catalog so their session-prefix message renders first. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
|
||||
@@ -80,10 +80,11 @@ export interface SkillConfig {
|
||||
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||
* order), the `tools` object to the tool registry (its presentation `mode`),
|
||||
* `skills` to the skill registry/local provider/tool consumer, and
|
||||
* `workspaceContext` to the workspace-context plugin. Every field is optional
|
||||
* INPUT here because each owner's schema supplies the default; the schema is
|
||||
* the INTERSECTION of the owners' own schemas (with child schemas nested under
|
||||
* their bundle keys), so validation and defaulting can never drift from them.
|
||||
* `workspaceContext` to the workspace-context plugin. Workspace context must
|
||||
* be configured explicitly with a byte budget or disabled with `false`; the
|
||||
* other fields remain optional inputs whose owner schemas supply defaults. The
|
||||
* schema is the INTERSECTION of the owners' own schemas (with child schemas
|
||||
* nested under their bundle keys), so validation and defaulting cannot drift.
|
||||
*/
|
||||
export interface Config {
|
||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||
@@ -94,8 +95,8 @@ export interface Config {
|
||||
toolOrder?: SystemPromptConfig['toolOrder']
|
||||
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
|
||||
tools?: ToolsConfig
|
||||
/** Workspace-context loader controls; set `false` for hermetic prompts. */
|
||||
workspaceContext?: workspaceContext.Config | false
|
||||
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
|
||||
workspaceContext: workspaceContext.Config | false
|
||||
/** Skill registry, local provider, and model-facing consumer config. */
|
||||
skills?: SkillConfig
|
||||
}
|
||||
@@ -114,7 +115,7 @@ export const Config = z.intersect([
|
||||
z.object({
|
||||
tools: ToolRegistry.Config,
|
||||
skills: SkillConfigSchema,
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
}) as unknown as z<Pick<Config, 'tools' | 'skills' | 'workspaceContext'>>,
|
||||
]) as unknown as z<Config>
|
||||
|
||||
@@ -122,7 +123,7 @@ export const Config = z.intersect([
|
||||
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
|
||||
* `agent-loop` receives the forwarded `agents` list and `system-prompt` the
|
||||
* forwarded `persona` and `toolOrder`. Workspace-context receives its own
|
||||
* forwarded config or loads with defaults. Load order is irrelevant (cordis
|
||||
* explicitly forwarded config. Load order is irrelevant (cordis
|
||||
* pends each fiber on its `inject` until the services it needs exist), but the
|
||||
* listing mirrors the dependency layering for readability: the LLM vocabulary
|
||||
* and core registries first, then extension plugins that wrap request/tool
|
||||
@@ -149,7 +150,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
if (config.workspaceContext !== false) {
|
||||
ctx.plugin(workspaceContext, config.workspaceContext ?? {})
|
||||
ctx.plugin(workspaceContext, config.workspaceContext)
|
||||
}
|
||||
// Both plugins prepend session-prefix messages. Registration order is the
|
||||
// rendered order, so workspace instructions must precede the skill catalog.
|
||||
|
||||
@@ -30,7 +30,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||
* Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless
|
||||
* bin smokes; here we assert the composition + config forwarding.
|
||||
*/
|
||||
async function mount(config?: agentCore.Config): Promise<Context> {
|
||||
async function mount(config: agentCore.Config): Promise<Context> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
|
||||
@@ -94,7 +94,7 @@ function messageText(message: Message | undefined): string {
|
||||
|
||||
describe('dsh-agent-core bundle', () => {
|
||||
it('brings up the full default spine', async () => {
|
||||
const ctx = await mount()
|
||||
const ctx = await mount({ workspaceContext: false })
|
||||
// One service from each layer of the spine proves the children loaded.
|
||||
expect(ctx.get('timer')).toBeDefined()
|
||||
expect(ctx.get('llm')).toBeDefined()
|
||||
@@ -108,7 +108,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
})
|
||||
|
||||
it('includes the skill registry, local provider, and skill tool without builtin skills', async () => {
|
||||
const ctx = await mount()
|
||||
const ctx = await mount({ workspaceContext: false })
|
||||
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill')
|
||||
@@ -118,7 +118,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
})
|
||||
|
||||
it('defaults the agents list to empty (no pre-created agents)', async () => {
|
||||
const ctx = await mount()
|
||||
const ctx = await mount({ workspaceContext: false })
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -127,6 +127,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
const ctx = await mount({
|
||||
agents: [{ id: AgentId('main'), model: 'mock' }],
|
||||
persona: 'You are main.',
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
@@ -138,7 +139,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
// ctx.plugin validates + defaults the bundle config first; a direct apply
|
||||
// skips the schema, so the forwarding `?? []` / `?? ''` are what fire.
|
||||
const ctx = new Context()
|
||||
agentCore.apply(ctx, {})
|
||||
agentCore.apply(ctx, { workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('agents')?.list()).toHaveLength(0)
|
||||
@@ -153,7 +154,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await writeFile(join(root, 'AGENTS.md'), 'bundled project rule')
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await mount()
|
||||
const ctx = await mount({ workspaceContext: { maxBytes: 65536 } })
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const handle = ctx.agents.create({
|
||||
@@ -213,6 +214,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n')
|
||||
const ctx = await mount({
|
||||
agents: [],
|
||||
workspaceContext: false,
|
||||
skills: {
|
||||
registry: { collectCacheMaxEntries: 4 },
|
||||
local: {
|
||||
@@ -234,7 +236,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await writeFile(join(root, 'AGENTS.md'), 'workspace rule before skills')
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await mount()
|
||||
const ctx = await mount({ workspaceContext: { maxBytes: 65536 } })
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.skills.register({
|
||||
@@ -265,7 +267,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
it('uses the default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
agentCore.apply(ctx, { agents: [] })
|
||||
agentCore.apply(ctx, { agents: [], workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
@@ -274,7 +276,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
})
|
||||
|
||||
it('forwards toolOrder to the system-prompt assembly', async () => {
|
||||
const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] })
|
||||
const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST], workspaceContext: false })
|
||||
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
|
||||
// this providerless mount, so register two plain tools to order.
|
||||
for (const name of ['alpha', 'zulu']) {
|
||||
|
||||
@@ -76,7 +76,7 @@ async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', si
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque version token from a stat: mtime (ns precision) + size. */
|
||||
/** Opaque version token from a stat: millisecond mtime plus byte size. */
|
||||
function versionOf(info: Stats): FsVersion {
|
||||
return FsVersion(`${info.mtimeMs}:${info.size}`)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ The core `context/message` envelope is disabled for these messages because the p
|
||||
|
||||
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context returned by `tools/post-execute` but not yet appended by the loop.
|
||||
|
||||
An unchanged path and SHA-256 content digest is not injected again. Resume works because visible metadata is persisted in the session log. Compaction re-arms a scope after its context event leaves the visible surface. A removal is a tombstone, so a later candidate reappearance is loaded again. Only changes actually rendered within the byte budget enter metadata and pending state; an omitted change remains eligible for a later touch.
|
||||
An unchanged path and SHA-1 content digest is not injected again. Resume works because visible metadata is persisted in the session log. Compaction re-arms a scope after its context event leaves the visible surface. A removal is a tombstone, so a later candidate reappearance is loaded again. Only changes actually rendered within the byte budget enter metadata and pending state; an omitted change remains eligible for a later touch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -58,12 +58,12 @@ The frozen baseline itself is not rewritten mid-instance. Its initial path/diges
|
||||
export interface Config {
|
||||
dshHome?: string
|
||||
projectRootMarkers?: string[]
|
||||
maxBytes?: number
|
||||
maxBytes: number
|
||||
instructionFileCandidates?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
`projectRootMarkers` defaults to `['.git']`, `maxBytes` to `65536`, and `instructionFileCandidates` to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
|
||||
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
|
||||
|
||||
The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite byte budget disables both baseline and dynamic loading.
|
||||
|
||||
@@ -71,7 +71,7 @@ The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only co
|
||||
|
||||
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`.
|
||||
|
||||
File text is cached by normalized absolute path plus the provider's opaque version and optional size. A signature change causes a re-read. Discovery carries the signature into reading so a cache hit does not stat the same file twice in one pass. Cache identity is separate from loaded-state identity: persisted structured metadata, not cached prose, controls duplicate suppression.
|
||||
Each discovered candidate is read and identified by normalized absolute path, the provider's opaque version, and a SHA-1 content digest. Comparing the digest after reading prevents a same-version, same-size rewrite from returning stale cached text. Discovery carries the provider version into reading so one pass does not stat the same file twice. Cache identity is separate from loaded-state identity: persisted structured metadata, not cached prose, controls duplicate suppression.
|
||||
|
||||
## Non-goals
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Configuration normalization for workspace instruction discovery and rendering.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/config
|
||||
*/
|
||||
|
||||
import z from 'schemastery'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
const DEFAULT_MAX_BYTES = 64 * 1024
|
||||
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
|
||||
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
|
||||
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
|
||||
@@ -12,8 +17,8 @@ export interface Config {
|
||||
dshHome?: string
|
||||
/** Directory entries that identify the project root while walking upward from the session cwd. */
|
||||
projectRootMarkers?: string[]
|
||||
/** Maximum UTF-8 bytes in one rendered baseline or dynamic instruction batch; non-positive disables loading. */
|
||||
maxBytes?: number
|
||||
/** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
|
||||
maxBytes: number
|
||||
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
|
||||
instructionFileCandidates?: string[]
|
||||
}
|
||||
@@ -21,28 +26,45 @@ export interface Config {
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
|
||||
maxBytes: z.number().default(DEFAULT_MAX_BYTES),
|
||||
maxBytes: z.number().required(),
|
||||
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
|
||||
})
|
||||
|
||||
/** Fully defaulted configuration used by discovery and reconciliation. */
|
||||
export interface ResolvedConfig {
|
||||
/** Normalized instruction discovery configuration. */
|
||||
export interface ResolvedDiscoveryConfig {
|
||||
dshHome: string
|
||||
projectRootMarkers: string[]
|
||||
maxBytes: number
|
||||
instructionFileCandidates: string[]
|
||||
}
|
||||
|
||||
/** Normalized configuration used by discovery and reconciliation. */
|
||||
export interface ResolvedConfig extends ResolvedDiscoveryConfig {
|
||||
maxBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve defaults, the harness home, and valid same-directory candidates.
|
||||
* @param config - user-facing plugin configuration.
|
||||
* @returns normalized runtime configuration.
|
||||
*/
|
||||
export function resolveConfig(config: Config): ResolvedConfig {
|
||||
return {
|
||||
...resolveDiscoveryConfig(config),
|
||||
maxBytes: config.maxBytes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the subset of configuration used before instruction content is rendered.
|
||||
* @param config - optional discovery controls.
|
||||
* @returns normalized home, root markers, and instruction candidates.
|
||||
*/
|
||||
export function resolveDiscoveryConfig(
|
||||
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates'>,
|
||||
): ResolvedDiscoveryConfig {
|
||||
return {
|
||||
dshHome: resolveDshHome(config.dshHome),
|
||||
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
|
||||
maxBytes: config.maxBytes ?? DEFAULT_MAX_BYTES,
|
||||
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates),
|
||||
}
|
||||
}
|
||||
|
||||
16
packages/prompt/workspace-context/src/digest.ts
Normal file
16
packages/prompt/workspace-context/src/digest.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Content identity for workspace instruction caching and duplicate suppression.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/digest
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Compute the content identity used across instruction loading and session state.
|
||||
* @param content - exact UTF-8 instruction text.
|
||||
* @returns lowercase SHA-1 digest in hexadecimal form.
|
||||
*/
|
||||
export function instructionContentSha1(content: string): string {
|
||||
return createHash('sha1').update(content).digest('hex')
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* Instruction-file discovery, provider reads, and content-aware caching.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/files
|
||||
*/
|
||||
|
||||
import { lstat, readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { FileSystem, FsInfo, FsPathInfo, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveConfig, type ResolvedConfig } from './config.ts'
|
||||
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
|
||||
import { instructionContentSha1 } from './digest.ts'
|
||||
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
|
||||
|
||||
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||
@@ -18,10 +25,10 @@ export interface LoadedInstructionFile extends InstructionFile {
|
||||
|
||||
interface FileSignature {
|
||||
version: string
|
||||
size: number | undefined
|
||||
}
|
||||
|
||||
interface CachedContent extends FileSignature {
|
||||
sha1: string
|
||||
content: string
|
||||
}
|
||||
|
||||
@@ -30,7 +37,7 @@ interface DiscoveredInstructionFile extends InstructionFile {
|
||||
target?: FsTarget
|
||||
}
|
||||
|
||||
/** Provider-signature-keyed content cache shared across plugin hooks. */
|
||||
/** Provider-version and SHA-1 keyed content cache shared across plugin hooks. */
|
||||
export type InstructionContentCache = Map<string, CachedContent>
|
||||
|
||||
interface DiscoverOptions {
|
||||
@@ -41,7 +48,7 @@ interface DiscoverOptions {
|
||||
}
|
||||
|
||||
interface LoadOptions extends DiscoverOptions {
|
||||
maxBytes?: number
|
||||
maxBytes: number
|
||||
cache?: InstructionContentCache
|
||||
}
|
||||
|
||||
@@ -61,7 +68,7 @@ async function nodeStatFile(path: string): Promise<FileSignature | undefined> {
|
||||
try {
|
||||
const info = await lstat(path)
|
||||
if (!info.isFile()) return undefined
|
||||
return { version: `${info.mtimeMs}:${info.size}`, size: info.size }
|
||||
return { version: String(info.mtimeMs) }
|
||||
} catch {
|
||||
// Candidates can disappear while discovery is in progress.
|
||||
return undefined
|
||||
@@ -78,7 +85,7 @@ async function fsStatFile(
|
||||
const target = await fileSystem.resolve(path)
|
||||
const info = await fileSystem.stat(target)
|
||||
if (info?.type !== 'file') return undefined
|
||||
return { version: info.version, size: info.size, target }
|
||||
return { version: info.version, target }
|
||||
} catch {
|
||||
// Provider absence and discovery races are both non-fatal.
|
||||
return undefined
|
||||
@@ -204,7 +211,7 @@ async function discoverInstructionFiles(
|
||||
options: DiscoverOptions,
|
||||
fileSystem?: FileSystem,
|
||||
): Promise<DiscoveredInstructionFile[]> {
|
||||
const config = resolveConfig(options)
|
||||
const config = resolveDiscoveryConfig(options)
|
||||
const files: DiscoveredInstructionFile[] = []
|
||||
const seen = new Set<string>()
|
||||
const addFile = (file: DiscoveredInstructionFile): void => {
|
||||
@@ -250,15 +257,14 @@ async function readCached(
|
||||
): Promise<string | undefined> {
|
||||
const path = file.absolutePath
|
||||
const { signature } = file
|
||||
const cached = cache.get(path)
|
||||
if (cached !== undefined && cached.version === signature.version && cached.size === signature.size) {
|
||||
return cached.content
|
||||
}
|
||||
try {
|
||||
const content = fileSystem === undefined || file.target === undefined
|
||||
? await readFile(path, 'utf8')
|
||||
: await fileSystem.readText(file.target)
|
||||
cache.set(path, { ...signature, content })
|
||||
const sha1 = instructionContentSha1(content)
|
||||
const cached = cache.get(path)
|
||||
if (cached !== undefined && cached.version === signature.version && cached.sha1 === sha1) return cached.content
|
||||
cache.set(path, { ...signature, sha1, content })
|
||||
return content
|
||||
} catch {
|
||||
// A file may disappear or become unreadable after its metadata probe.
|
||||
@@ -345,7 +351,7 @@ export async function loadScopeInstruction(
|
||||
const discovered: DiscoveredInstructionFile = {
|
||||
absolutePath,
|
||||
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
|
||||
signature: { version: info.version, size: info.size },
|
||||
signature: { version: info.version },
|
||||
target,
|
||||
}
|
||||
const content = await readCached(discovered, cache, fileSystem)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Model-facing workspace instruction rendering within an explicit byte budget.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/render
|
||||
*/
|
||||
|
||||
import { dirname } from 'node:path'
|
||||
import type { InstructionFile, LoadedInstructionFile } from './files.ts'
|
||||
|
||||
@@ -15,7 +21,7 @@ export interface TruncatedInstruction {
|
||||
includedBytes: number
|
||||
}
|
||||
|
||||
/** Bounded model-facing text plus omitted and truncated source records. */
|
||||
/** Model-facing text plus omitted and truncated source records. */
|
||||
export interface RenderedWorkspaceContext {
|
||||
text: string
|
||||
omitted: InstructionFile[]
|
||||
@@ -232,7 +238,7 @@ function renderInstructionContext(
|
||||
/**
|
||||
* Render the baseline instruction chain with deterministic precedence budgeting.
|
||||
* @param files - loaded files ordered from broadest to most specific.
|
||||
* @param options - rendering byte budget.
|
||||
* @param options - required rendering byte budget.
|
||||
* @returns bounded baseline prompt text and budget diagnostics.
|
||||
*/
|
||||
export function renderWorkspaceContext(
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
/**
|
||||
* Session-visible workspace instruction state and dynamic reconciliation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workspace-context/state
|
||||
*/
|
||||
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { renderContextContent, type JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { FileSystem } 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 {
|
||||
ancestorChain,
|
||||
descendantDirsBetween,
|
||||
@@ -38,10 +44,6 @@ export interface WorkspaceHookContext extends HookContext {
|
||||
meta: JsonValue
|
||||
}
|
||||
|
||||
function digest(content: string): string {
|
||||
return createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
|
||||
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext {
|
||||
const serializedChanges: JsonValue[] = changes.map(change => ({
|
||||
action: change.action,
|
||||
@@ -154,7 +156,7 @@ export function baselineInstructionChanges(files: LoadedInstructionFile[]): Map<
|
||||
action: 'set',
|
||||
scope: scopeForDisplayPath(file.displayPath),
|
||||
path: file.displayPath,
|
||||
digest: digest(file.content),
|
||||
digest: instructionContentSha1(file.content),
|
||||
}
|
||||
return [change.scope, change]
|
||||
}))
|
||||
@@ -181,7 +183,7 @@ function relativeScope(projectRoot: string, dir: string): string {
|
||||
* Compare visible/pending state with provider-visible files and render transitions.
|
||||
* @param agent - session owner whose visible surface supplies durable state.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param cache - shared provider-signature content cache.
|
||||
* @param cache - shared provider-version and content-digest cache.
|
||||
* @param pendingBySession - short pending window before returned context is logged.
|
||||
* @param baselineBySession - frozen baseline comparison state per session.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
@@ -245,7 +247,7 @@ export async function reconcileInstructionContext(
|
||||
}
|
||||
continue
|
||||
}
|
||||
const currentDigest = digest(file.content)
|
||||
const currentDigest = instructionContentSha1(file.content)
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) continue
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
|
||||
@@ -275,7 +277,7 @@ export async function reconcileInstructionContext(
|
||||
* @param exec - completed tool execution descriptor.
|
||||
* @param result - original tool result before post-execute decisions.
|
||||
* @param resolved - normalized plugin configuration.
|
||||
* @param cache - shared provider-signature content cache.
|
||||
* @param cache - shared provider-version and content-digest cache.
|
||||
* @param pendingNestedChanges - per-session pending transition maps.
|
||||
* @param baselineInstructionStates - retained baseline comparison state.
|
||||
* @param fileSystem - provider used for current file probes.
|
||||
|
||||
@@ -42,7 +42,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(WorkspaceContext)
|
||||
await ctx.plugin(WorkspaceContext, { maxBytes: 65536 })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
|
||||
const handle = ctx.agents.create({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
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'
|
||||
@@ -219,7 +219,7 @@ describe('workspace context instruction discovery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('re-walks the baseline path and re-reads content when file signatures change', async () => {
|
||||
it('refreshes cached content after a same-version, same-size rewrite', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -228,19 +228,20 @@ describe('workspace context instruction discovery', () => {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
|
||||
const cache: InstructionContentCache = new Map()
|
||||
expect(await loadBaselineInstructions({ cwd, dshHome: home, cache })).toBeUndefined()
|
||||
expect(await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })).toBeUndefined()
|
||||
|
||||
const leaf = join(cwd, 'AGENTS.md')
|
||||
await write(leaf, 'first')
|
||||
const first = await loadBaselineInstructions({ cwd, dshHome: home, cache })
|
||||
const first = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })
|
||||
expect(first?.text).toContain('first')
|
||||
const cached = await loadBaselineInstructions({ cwd, dshHome: home, cache })
|
||||
const cached = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })
|
||||
expect(cached?.text).toContain('first')
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
await writeFile(leaf, 'second and longer')
|
||||
const second = await loadBaselineInstructions({ cwd, dshHome: home, cache })
|
||||
expect(second?.text).toContain('second and longer')
|
||||
const before = await stat(leaf)
|
||||
await writeFile(leaf, 'other')
|
||||
await utimes(leaf, before.atime, before.mtime)
|
||||
const second = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536, cache })
|
||||
expect(second?.text).toContain('other')
|
||||
expect(second?.text).not.toContain('first')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -259,7 +260,7 @@ describe('workspace context instruction discovery', () => {
|
||||
await write(leaf, 'secret-ish rule')
|
||||
await chmod(leaf, 0)
|
||||
|
||||
const loaded = await loadBaselineInstructions({ cwd, dshHome: home })
|
||||
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
|
||||
|
||||
expect(loaded).toBeUndefined()
|
||||
await chmod(leaf, 0o600)
|
||||
@@ -279,7 +280,7 @@ describe('workspace context instruction discovery', () => {
|
||||
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
|
||||
|
||||
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: home })
|
||||
const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home })
|
||||
const loaded = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
|
||||
|
||||
expect(files).toEqual([])
|
||||
expect(loaded).toBeUndefined()
|
||||
@@ -299,7 +300,7 @@ describe('workspace context instruction discovery', () => {
|
||||
await write(join(outside, 'secret.txt'), 'outside secret')
|
||||
await symlink(join(outside, 'secret.txt'), join(root, 'AGENTS.md'))
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -655,11 +656,17 @@ describe('workspace context rendering', () => {
|
||||
})
|
||||
|
||||
describe('workspace context request injection', () => {
|
||||
it('requires an explicit maxBytes configuration', async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/)
|
||||
})
|
||||
|
||||
it('mounts without requiring a filesystem provider', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
const outcome = await Promise.race([
|
||||
ctx.plugin(workspaceContext, {}).then(() => {
|
||||
ctx.plugin(workspaceContext, { maxBytes: 65536 }).then(() => {
|
||||
return 'settled' as const
|
||||
}),
|
||||
new Promise<'pending'>((resolve) => {
|
||||
@@ -682,7 +689,7 @@ describe('workspace context request injection', () => {
|
||||
it('does not inject baseline context when no filesystem provider is present', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(workspaceContext, {})
|
||||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||||
const agent = stubAgent('/virtual/repo')
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -696,7 +703,7 @@ describe('workspace context request injection', () => {
|
||||
it('leaves post-execute decisions unchanged when no filesystem provider is present', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(workspaceContext, {})
|
||||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||||
|
||||
const decision = await ctx.waterfall('tools/post-execute', {
|
||||
callId: CallId('no-fs-post-execute'),
|
||||
@@ -725,7 +732,7 @@ describe('workspace context request injection', () => {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -750,7 +757,7 @@ describe('workspace context request injection', () => {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await composeBaselinePrefix(ctx, agent)
|
||||
@@ -794,7 +801,7 @@ describe('workspace context request injection', () => {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
|
||||
const rest = await next()
|
||||
return [{ role: 'user', content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }] }, ...rest]
|
||||
@@ -819,7 +826,7 @@ describe('workspace context request injection', () => {
|
||||
await write(join(root, 'AGENTS.md'), 'old root rule')
|
||||
await write(join(root, 'file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -847,7 +854,7 @@ describe('workspace context request injection', () => {
|
||||
await write(join(root, 'AGENTS.md'), 'root rule')
|
||||
await write(join(root, 'file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -873,7 +880,7 @@ describe('workspace context request injection', () => {
|
||||
await write(join(root, 'AGENTS.md'), 'shared root and global rule')
|
||||
await write(join(root, 'file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: root })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: root, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -919,14 +926,14 @@ describe('workspace context request injection', () => {
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(derivedText(agent)).toContain('ctx.fs rule')
|
||||
expect(derivedText(agent)).not.toContain('node fs rule')
|
||||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
|
||||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md'), join(root, 'AGENTS.md')])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -942,13 +949,13 @@ describe('workspace context request injection', () => {
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(derivedText(agent)).toContain('provider-only rule')
|
||||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md')])
|
||||
expect(fs.readTargets).toEqual([join(root, 'AGENTS.md'), join(root, 'AGENTS.md')])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(dirname(root), { recursive: true, force: true })
|
||||
@@ -969,7 +976,7 @@ describe('workspace context request injection', () => {
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' })
|
||||
fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -995,7 +1002,7 @@ describe('workspace context request injection', () => {
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -1019,7 +1026,7 @@ describe('workspace context request injection', () => {
|
||||
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 })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -1042,7 +1049,7 @@ describe('workspace context request injection', () => {
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -1065,7 +1072,7 @@ describe('workspace context request injection', () => {
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.throwOnStat.add(join(root, 'AGENTS.md'))
|
||||
await ctx.plugin(workspaceContext, { dshHome: home })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -1088,7 +1095,7 @@ describe('workspace context request injection', () => {
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.throwOnStat.add(join(root, '.git'))
|
||||
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -1110,7 +1117,7 @@ describe('workspace context request injection', () => {
|
||||
await write(join(repoA, 'AGENTS.md'), 'repo A only')
|
||||
await write(join(repoB, 'AGENTS.md'), 'repo B only')
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agentA = stubAgent(repoA)
|
||||
const agentB = stubAgent(repoB)
|
||||
|
||||
@@ -1138,7 +1145,7 @@ describe('workspace context request injection', () => {
|
||||
await write(join(cwd, 'AGENTS.md'), 'child schema default rule')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
await ctx.plugin(workspaceContext, {})
|
||||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||||
const agent = stubAgent(cwd)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -1158,7 +1165,7 @@ describe('workspace context request injection', () => {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
const ctx = new Context()
|
||||
const fiber = await mountWorkspaceContext(ctx, { dshHome: home })
|
||||
const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
await fiber.dispose()
|
||||
const agent = stubAgent(root)
|
||||
|
||||
@@ -1215,7 +1222,7 @@ describe('workspace context request injection', () => {
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
const ctx = new Context()
|
||||
await mountWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
@@ -1241,7 +1248,7 @@ describe('workspace context request injection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reuses the discovery lstat signature when reading cached content', async () => {
|
||||
it('does not repeat a candidate metadata probe during one discovery and read pass', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
@@ -1263,9 +1270,9 @@ describe('workspace context request injection', () => {
|
||||
const isolated = await import('@deepseek-ai/dsh-workspace-context')
|
||||
const cache: InstructionContentCache = new Map()
|
||||
|
||||
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache })
|
||||
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, cache })
|
||||
observedStats.clear()
|
||||
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache })
|
||||
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536, cache })
|
||||
|
||||
expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1)
|
||||
} finally {
|
||||
@@ -1287,7 +1294,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
@@ -1316,7 +1323,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange)
|
||||
? firstChange.digest
|
||||
: undefined
|
||||
expect(changeDigest).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(changeDigest).toMatch(/^[a-f0-9]{40}$/)
|
||||
const text = blocksText(result.additionalContext?.content)
|
||||
expect(text).toBe([
|
||||
'<system-reminder>',
|
||||
@@ -1346,6 +1353,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, {
|
||||
dshHome: home,
|
||||
maxBytes: 65536,
|
||||
instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'],
|
||||
})
|
||||
|
||||
@@ -1374,7 +1382,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
@@ -1406,7 +1414,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'old package rule')
|
||||
await write(join(root, 'pkg/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
@@ -1446,7 +1454,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/CLAUDE.md'), 'fallback package rule')
|
||||
await write(join(root, 'pkg/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
@@ -1485,7 +1493,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
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 })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
@@ -1523,7 +1531,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'first package rule')
|
||||
await write(join(root, 'pkg/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
@@ -1565,7 +1573,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'provider package rule' })
|
||||
fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(workspaceContext, { dshHome: home })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const first = await ctx.tools.execute({
|
||||
@@ -1594,7 +1602,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
const first = await ctx.tools.execute({
|
||||
callId: CallId('read-before-resume'),
|
||||
@@ -1631,7 +1639,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'old nested rule')
|
||||
await write(join(root, 'pkg/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const original = stubAgent(root)
|
||||
const first = await ctx.tools.execute({
|
||||
callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original,
|
||||
@@ -1661,7 +1669,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
const first = await ctx.tools.execute({
|
||||
callId: CallId('read-before-compact'),
|
||||
@@ -1709,7 +1717,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/sub/AGENTS.md'), 'subtree rule')
|
||||
await write(join(root, 'pkg/sub/file.txt'), 'subtree file')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
const first = await ctx.tools.execute({
|
||||
callId: CallId('read-package'),
|
||||
@@ -1780,7 +1788,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
agent.session.append('context/message', {
|
||||
content: [
|
||||
@@ -1838,7 +1846,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
|
||||
const rootResult = await ctx.tools.execute({
|
||||
@@ -1872,7 +1880,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
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 })
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
const result = {
|
||||
callId: CallId('provider-probe-result'),
|
||||
@@ -1908,7 +1916,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
await chmod(nested, 0)
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('read-with-unreadable-nested-instruction'),
|
||||
@@ -1934,7 +1942,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
ctx.on('tools/post-execute', async () => ({
|
||||
kind: 'accept' as const,
|
||||
content: [{ type: 'text' as const, text: 'downstream replacement' }],
|
||||
@@ -1977,7 +1985,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
ctx.on('tools/post-execute', async () => ({
|
||||
kind: 'block' as const,
|
||||
feedback: [{ type: 'text' as const, text: 'blocked downstream' }],
|
||||
@@ -2010,7 +2018,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
const agent = stubAgent(root)
|
||||
const result = {
|
||||
callId: CallId('manual'),
|
||||
@@ -2073,7 +2081,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('read-missing'),
|
||||
@@ -2098,7 +2106,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
const ctx = new Context()
|
||||
const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home })
|
||||
const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
await fiber.dispose()
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
|
||||
@@ -61,8 +61,8 @@ export interface Config {
|
||||
tools?: ToolsConfig
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */
|
||||
workspaceContext?: agentCore.Config['workspaceContext']
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
workspaceContext: agentCore.Config['workspaceContext']
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
skills?: agentCore.SkillConfig
|
||||
}
|
||||
@@ -76,9 +76,9 @@ export const Config: z<Config> = z.object({
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
}) as unknown as z<Config>
|
||||
})
|
||||
|
||||
/**
|
||||
* Compose the spine with the ACP front door. The agent-core bundle pre-creates
|
||||
@@ -92,7 +92,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
...config.tools !== undefined ? { tools: config.tools } : {},
|
||||
...config.workspaceContext !== undefined ? { workspaceContext: config.workspaceContext } : {},
|
||||
workspaceContext: config.workspaceContext,
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
ctx.plugin(UserInteractionService)
|
||||
|
||||
@@ -67,7 +67,7 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
|
||||
describe('dsh-acp-agent composition', () => {
|
||||
it('brings up the spine + persistence + the ACP bridge', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig() })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
@@ -86,7 +86,7 @@ describe('dsh-acp-agent composition', () => {
|
||||
// persistenceRoot, so the runtime fallback is the one that fires.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
|
||||
acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -107,7 +107,7 @@ describe('dsh-acp-agent composition', () => {
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
acpAgent.apply(ctx, { model: 'mock' })
|
||||
acpAgent.apply(ctx, { model: 'mock', workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
@@ -116,7 +116,7 @@ describe('dsh-acp-agent composition', () => {
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false })
|
||||
ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -132,6 +132,7 @@ describe('dsh-acp-agent composition', () => {
|
||||
model: 'mock',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
persistenceRoot: '/tmp/dsh-acp-agent-test-tool-order',
|
||||
workspaceContext: false,
|
||||
})
|
||||
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
|
||||
// this providerless mount, so register two plain tools to order.
|
||||
|
||||
@@ -84,8 +84,8 @@ export interface Config {
|
||||
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
|
||||
*/
|
||||
resumeSessionId?: string
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; set `false` for hermetic prompts. */
|
||||
workspaceContext?: agentCore.Config['workspaceContext']
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
workspaceContext: agentCore.Config['workspaceContext']
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -100,8 +100,8 @@ export const Config: z<Config> = z.object({
|
||||
welcome: z.string().default('ready.'),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
resumeSessionId: z.string(),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]),
|
||||
}) as unknown as z<Config>
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Compose the spine with the stdio front door. The console logger comes first
|
||||
@@ -122,7 +122,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
cwd: process.cwd(),
|
||||
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
|
||||
}],
|
||||
...config.workspaceContext !== undefined ? { workspaceContext: config.workspaceContext } : {},
|
||||
workspaceContext: config.workspaceContext,
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
|
||||
@@ -74,7 +74,7 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
|
||||
describe('dsh-stdio-agent app', () => {
|
||||
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
// The spine services (brought up by the agent-core bundle) are all present.
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
@@ -95,7 +95,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
// schema-bypassing direct-mount caller.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
|
||||
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
@@ -116,7 +116,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
stdioAgent.apply(ctx, { model: 'mock' })
|
||||
stdioAgent.apply(ctx, { model: 'mock', workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
@@ -134,13 +134,14 @@ describe('dsh-stdio-agent app', () => {
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
|
||||
resumeSessionId: 'no-such-session',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6), workspaceContext: false })
|
||||
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -156,6 +157,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
model: 'mock',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order',
|
||||
workspaceContext: false,
|
||||
})
|
||||
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
|
||||
// this providerless mount, so register two plain tools to order.
|
||||
|
||||
Reference in New Issue
Block a user