feat(coding-agent): added workspace-tree context APIs for system prompts

- Added `WorkspaceTree` and `buildWorkspaceTree` APIs for working-directory tree rendering with limits.
- Extended `buildSystemPrompt` and `createAgentSession` to resolve and pass workspace tree context for system prompts.
- Updated the system prompt template to include a `<workspace-tree>` section with truncation notices before append output.
- Added workspace-tree and system-prompt tests covering sorting, truncation, exclusions, and prompt ordering.
This commit is contained in:
can1357
2026-05-04 05:10:43 +02:00
parent e9735f6ad8
commit 45209e233c
7 changed files with 464 additions and 1 deletions
+3 -1
View File
@@ -1,17 +1,19 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed the top-level `sel` parameter from the `read` tool schema, requiring callers to migrate to `path`-embedded selectors (for example `path:50-100`, `path:raw`, or `https://...:L1-L40`)
### Added
- Added `buildWorkspaceTree` and `WorkspaceTree` exports so callers can precompute and pass a workspace context to prompt generation
- Added `workspaceTree` support to `buildSystemPrompt` options to reuse a prebuilt directory snapshot
- Added `read.summarize.enabled`, `read.summarize.minBodyLines`, and `read.summarize.minCommentLines` settings to control whether `read` returns structural summaries and how many multiline body/comment lines are collapsed
### Changed
- Changed generated system prompts to include a working-directory tree block after directory context, showing recent files/directories (depth ≤ 3) and truncation notices when entries are elided
- Changed `read` summary rendering to merge opening- and closing-brace boundaries around elided sections into a single `..` line (including closers like `};` or `})`), reducing those segments to one concise anchored summary line
- Changed default `read` output for parseable code files without an explicit selector to return a structural summary instead of full verbatim lines, while still supporting full output for `:raw` and explicit ranges
- Changed truncation/pagination hints in read, archive, and SQLite outputs to use colon syntax (`Use :<offset>`) when continuing reads
@@ -34,6 +34,17 @@ Some directories may have their own rules. Deeper rules override higher ones.
</dir-context>
{{/if}}
{{#if workspaceTree.rendered}}
<workspace-tree>
Working directory layout (sorted by mtime, recent first; depth ≤ 3):
{{workspaceTree.rendered}}
{{#if workspaceTree.truncated}}
(some entries elided to keep the tree short — use `find`/`read` to drill in)
{{/if}}
</workspace-tree>
{{/if}}
{{#if appendPrompt}}
{{appendPrompt}}
{{/if}}
+6
View File
@@ -140,6 +140,7 @@ import { wrapToolWithMetaNotice } from "./tools/output-meta";
import { queueResolveHandler } from "./tools/resolve";
import { EventBus } from "./utils/event-bus";
import { buildNamedToolChoice } from "./utils/tool-choice";
import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
// Types
export interface CreateAgentSessionOptions {
@@ -270,6 +271,7 @@ export type { Skill } from "./extensibility/skills";
export type { FileSlashCommand } from "./extensibility/slash-commands";
export type { MCPManager, MCPServerConfig, MCPServerConnection, MCPToolsLoadResult } from "./mcp";
export type { Tool } from "./tools";
export { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
export {
// Individual tool classes (for custom usage)
@@ -680,6 +682,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// (~200ms on large repos) and only needs `cwd`, so it can overlap with everything that follows.
const agentsMdSearchPromise: Promise<AgentsMdSearch> = logger.time("buildAgentsMdSearch", buildAgentsMdSearch, cwd);
agentsMdSearchPromise.catch(() => {});
const workspaceTreePromise: Promise<WorkspaceTree> = logger.time("buildWorkspaceTree", buildWorkspaceTree, cwd);
workspaceTreePromise.catch(() => {});
// Independent discoveries that depend only on cwd/agentDir — kicked off in parallel and awaited
// at their respective consumer sites. Their work can overlap with model resolution, secret loading,
@@ -1380,6 +1384,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
eagerTasks,
secretsEnabled,
agentsMdSearch: agentsMdSearchPromise,
workspaceTree: workspaceTreePromise,
});
if (options.systemPrompt === undefined) {
@@ -1404,6 +1409,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
eagerTasks,
secretsEnabled,
agentsMdSearch: agentsMdSearchPromise,
workspaceTree: workspaceTreePromise,
});
}
return options.systemPrompt(defaultPrompt);
@@ -14,6 +14,7 @@ import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile
import { loadSkills, type Skill } from "./extensibility/skills";
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
interface AlwaysApplyRule {
name: string;
@@ -409,6 +410,8 @@ export interface BuildSystemPromptOptions {
secretsEnabled?: boolean;
/** Pre-loaded AGENTS.md search (skips discovery if provided). May be a Promise to allow early kick-off. */
agentsMdSearch?: AgentsMdSearch | Promise<AgentsMdSearch>;
/** Pre-loaded workspace tree (skips discovery if provided). May be a Promise to allow early kick-off. */
workspaceTree?: WorkspaceTree | Promise<WorkspaceTree>;
}
/** Build the system prompt with tools, guidelines, and context */
@@ -435,6 +438,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
eagerTasks = false,
secretsEnabled = false,
agentsMdSearch: providedAgentsMdSearch,
workspaceTree: providedWorkspaceTree,
} = options;
const resolvedCwd = cwd ?? getProjectDir();
@@ -449,6 +453,10 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
providedAgentsMdSearch !== undefined
? Promise.resolve(providedAgentsMdSearch)
: logger.time("buildAgentsMdSearch", buildAgentsMdSearch, resolvedCwd);
const workspaceTreePromise =
providedWorkspaceTree !== undefined
? Promise.resolve(providedWorkspaceTree)
: logger.time("buildWorkspaceTree", buildWorkspaceTree, resolvedCwd);
const skillsPromise: Promise<Skill[]> =
providedSkills !== undefined
? Promise.resolve(providedSkills)
@@ -463,6 +471,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
contextFilesPromise,
agentsMdSearchPromise,
skillsPromise,
workspaceTreePromise,
]).then(
([
resolvedCustomPrompt,
@@ -471,6 +480,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
contextFiles,
agentsMdSearch,
skills,
workspaceTree,
]) => ({
resolvedCustomPrompt,
resolvedAppendPrompt,
@@ -478,6 +488,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
contextFiles,
agentsMdSearch,
skills,
workspaceTree,
}),
);
})();
@@ -501,6 +512,12 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
pattern: `AGENTS.md depth ${AGENTS_MD_MIN_DEPTH}-${AGENTS_MD_MAX_DEPTH}`,
files: [],
};
let workspaceTree: WorkspaceTree = {
rootPath: resolvedCwd,
rendered: "",
truncated: false,
totalLines: 0,
};
let skills: Skill[] = providedSkills ?? [];
if (prepResult.type === "timeout") {
@@ -524,6 +541,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
contextFiles = dedupeExactContextFiles(prepResult.value.contextFiles);
agentsMdSearch = prepResult.value.agentsMdSearch;
skills = prepResult.value.skills;
workspaceTree = prepResult.value.workspaceTree;
}
const date = new Date().toISOString().slice(0, 10);
@@ -578,6 +596,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
environment,
contextFiles,
agentsMdSearch,
workspaceTree,
skills: filteredSkills,
rules: rules ?? [],
alwaysApplyRules: injectedAlwaysApplyRules,
+271
View File
@@ -0,0 +1,271 @@
import * as path from "node:path";
import { glob } from "@oh-my-pi/pi-natives";
import { formatAge, formatBytes } from "@oh-my-pi/pi-utils";
export interface WorkspaceTree {
rootPath: string;
rendered: string;
truncated: boolean;
totalLines: number;
}
const WORKSPACE_TREE_MAX_DEPTH = 3;
const WORKSPACE_TREE_DIR_LIMIT = 12;
const WORKSPACE_TREE_LINE_CAP = 120;
const WORKSPACE_TREE_EXCLUDED_DIRS = new Set([
"node_modules",
".git",
".next",
"dist",
"build",
"target",
".venv",
".cache",
".turbo",
".parcel-cache",
"coverage",
]);
const GLOB_SPECIAL_CHARS = new Set(["!", "(", ")", "*", "?", "[", "]", "{", "}", "\\"]);
interface WorkspaceTreeNode {
name: string;
relativePath: string;
depth: number;
isDirectory: boolean;
mtimeMs: number;
size: number;
children: WorkspaceTreeNode[];
droppedChildCount: number;
}
interface RenderLine {
label: string;
depth: number;
size?: string;
age?: string;
isRoot?: boolean;
}
function emptyWorkspaceTree(rootPath: string): WorkspaceTree {
return {
rootPath,
rendered: "",
truncated: false,
totalLines: 0,
};
}
function compareByRecency(a: WorkspaceTreeNode, b: WorkspaceTreeNode): number {
const mtimeCompare = b.mtimeMs - a.mtimeMs;
if (mtimeCompare !== 0) return mtimeCompare;
return a.name.localeCompare(b.name);
}
function childRelativePath(parentRelativePath: string, name: string): string {
return parentRelativePath ? `${parentRelativePath}/${name}` : name;
}
function escapeGlobSegment(segment: string): string {
return Array.from(segment, char => (GLOB_SPECIAL_CHARS.has(char) ? `\\${char}` : char)).join("");
}
function directChildPattern(parentRelativePath: string): string {
if (!parentRelativePath) return "*";
return `${parentRelativePath.split("/").map(escapeGlobSegment).join("/")}/*`;
}
function matchChildName(parentRelativePath: string, matchPath: string): string | null {
if (!parentRelativePath) return matchPath.includes("/") ? null : matchPath;
const prefix = `${parentRelativePath}/`;
if (!matchPath.startsWith(prefix)) return null;
const name = matchPath.slice(prefix.length);
return name.includes("/") ? null : name;
}
async function listWorkspaceTreeChildren(rootPath: string, parent: WorkspaceTreeNode): Promise<WorkspaceTreeNode[]> {
const result = await glob({
pattern: directChildPattern(parent.relativePath),
path: rootPath,
recursive: false,
hidden: false,
gitignore: true,
cache: true,
});
const children = await Promise.all(
result.matches.map(async (match): Promise<WorkspaceTreeNode | null> => {
const name = matchChildName(parent.relativePath, match.path);
if (!name) return null;
if (name.startsWith(".")) return null;
const absolutePath = path.join(rootPath, childRelativePath(parent.relativePath, name));
try {
const stat = await Bun.file(absolutePath).stat();
const isDirectory = stat.isDirectory();
if (isDirectory && WORKSPACE_TREE_EXCLUDED_DIRS.has(name)) return null;
return {
name,
relativePath: childRelativePath(parent.relativePath, name),
depth: parent.depth + 1,
isDirectory,
mtimeMs: stat.mtimeMs,
size: stat.size,
children: [],
droppedChildCount: 0,
} satisfies WorkspaceTreeNode;
} catch {
return null;
}
}),
);
return children.filter((child): child is WorkspaceTreeNode => child !== null).sort(compareByRecency);
}
function applyDirectoryLimit(children: WorkspaceTreeNode[]): {
visibleChildren: WorkspaceTreeNode[];
droppedCount: number;
} {
if (children.length <= WORKSPACE_TREE_DIR_LIMIT) {
return { visibleChildren: children, droppedCount: 0 };
}
const recentChildren = children.slice(0, WORKSPACE_TREE_DIR_LIMIT - 1);
const oldestChild = children[children.length - 1];
return {
visibleChildren: oldestChild ? [...recentChildren, oldestChild] : recentChildren,
droppedCount: children.length - WORKSPACE_TREE_DIR_LIMIT,
};
}
async function collectWorkspaceTree(rootPath: string): Promise<{ root: WorkspaceTreeNode; truncated: boolean }> {
const rootStat = await Bun.file(rootPath).stat();
const root: WorkspaceTreeNode = {
name: ".",
relativePath: "",
depth: 0,
isDirectory: true,
mtimeMs: rootStat.mtimeMs,
size: rootStat.size,
children: [],
droppedChildCount: 0,
};
let truncated = false;
const queue: WorkspaceTreeNode[] = [root];
let cursor = 0;
while (cursor < queue.length) {
const parent = queue[cursor];
cursor += 1;
if (!parent || parent.depth >= WORKSPACE_TREE_MAX_DEPTH) continue;
const children = await listWorkspaceTreeChildren(rootPath, parent);
const limited = applyDirectoryLimit(children);
parent.children = limited.visibleChildren;
parent.droppedChildCount = limited.droppedCount;
if (limited.droppedCount > 0) truncated = true;
for (const child of parent.children) {
if (child.isDirectory) queue.push(child);
}
}
return { root, truncated };
}
function formatNodeAge(nowMs: number, mtimeMs: number): string {
const ageSeconds = Math.max(0, Math.floor((nowMs - mtimeMs) / 1000));
return formatAge(ageSeconds);
}
function pushNodeLine(lines: RenderLine[], node: WorkspaceTreeNode, nowMs: number): void {
if (node.depth === 0) {
lines.push({ label: ".", depth: 0, isRoot: true });
return;
}
const indent = " ".repeat(node.depth);
const suffix = node.isDirectory ? "/" : "";
lines.push({
label: `${indent}- ${node.name}${suffix}`,
depth: node.depth,
size: node.isDirectory ? undefined : formatBytes(node.size),
age: formatNodeAge(nowMs, node.mtimeMs),
});
}
function pushDroppedChildrenLine(lines: RenderLine[], parent: WorkspaceTreeNode): void {
if (parent.droppedChildCount <= 0) return;
const childDepth = parent.depth + 1;
const indent = " ".repeat(childDepth);
lines.push({
label: `${indent}- … ${parent.droppedChildCount} more`,
depth: childDepth,
});
}
function collectRenderLines(node: WorkspaceTreeNode, nowMs: number, lines: RenderLine[]): void {
pushNodeLine(lines, node, nowMs);
if (node.droppedChildCount > 0) {
const recentChildren = node.children.slice(0, WORKSPACE_TREE_DIR_LIMIT - 1);
const oldestChild = node.children[node.children.length - 1];
for (const child of recentChildren) collectRenderLines(child, nowMs, lines);
pushDroppedChildrenLine(lines, node);
if (oldestChild && !recentChildren.includes(oldestChild)) collectRenderLines(oldestChild, nowMs, lines);
return;
}
for (const child of node.children) collectRenderLines(child, nowMs, lines);
}
function applyLineCap(lines: RenderLine[]): { lines: RenderLine[]; elidedCount: number } {
if (lines.length <= WORKSPACE_TREE_LINE_CAP) return { lines, elidedCount: 0 };
const targetLineCount = WORKSPACE_TREE_LINE_CAP - 1;
const removeCount = lines.length - targetLineCount;
const removable = lines
.map((line, index) => ({ line, index }))
.filter(item => !item.line.isRoot)
.sort((a, b) => b.line.depth - a.line.depth || b.index - a.index)
.slice(0, removeCount);
const removedIndexes = new Set(removable.map(item => item.index));
const cappedLines = lines.filter((_, index) => !removedIndexes.has(index));
cappedLines.push({
label: `… (${removeCount} lines elided beyond depth/cap)`,
depth: 0,
});
return { lines: cappedLines, elidedCount: removeCount };
}
function renderLines(lines: RenderLine[]): string {
const maxLabelLength = lines.reduce((max, line) => Math.max(max, line.label.length), 0);
return lines
.map(line => {
if (!line.age) return line.label;
const sizeColumn = (line.size ?? "").padEnd(8);
return `${line.label.padEnd(maxLabelLength + 2)}${sizeColumn} ${line.age.padEnd(4)}`.trimEnd();
})
.join("\n");
}
export async function buildWorkspaceTree(cwd: string): Promise<WorkspaceTree> {
const rootPath = path.resolve(cwd);
try {
const nowMs = Date.now();
const { root, truncated: directoryTruncated } = await collectWorkspaceTree(rootPath);
const lines: RenderLine[] = [];
collectRenderLines(root, nowMs, lines);
const { lines: cappedLines, elidedCount } = applyLineCap(lines);
return {
rootPath,
rendered: renderLines(cappedLines),
truncated: directoryTruncated || elidedCount > 0,
totalLines: cappedLines.length,
};
} catch {
return emptyWorkspaceTree(rootPath);
}
}
@@ -203,6 +203,35 @@ describe("system Handlebars prompt templates", () => {
expect(rendered).toContain("call `search_tool_bm25` before concluding no such tool exists");
});
test("buildSystemPrompt renders workspace tree after directory context", async () => {
await withTempDir(async dir => {
const systemPrompt = await buildSystemPrompt({
cwd: dir,
contextFiles: [],
skills: [],
rules: [],
toolNames: ["read"],
agentsMdSearch: {
scopePath: ".",
limit: 200,
pattern: "AGENTS.md depth 1-4",
files: ["packages/coding-agent/AGENTS.md"],
},
workspaceTree: {
rootPath: dir,
rendered: ".\n - src/ 1m",
truncated: true,
totalLines: 2,
},
});
expect(systemPrompt).toContain("<workspace-tree>");
expect(systemPrompt).toContain("Working directory layout (sorted by mtime, recent first; depth ≤ 3):");
expect(systemPrompt).toContain("(some entries elided to keep the tree short");
expect(systemPrompt.indexOf("</dir-context>")).toBeLessThan(systemPrompt.indexOf("<workspace-tree>"));
});
});
test("buildSystemPrompt deduplicates always-apply rules already present in SYSTEM.md", async () => {
const duplicateRule = ["Use static imports.", "", "Do not use dynamic loading."].join("\n");
const distinctRule = "Validate inputs at boundaries.";
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { buildWorkspaceTree } from "@oh-my-pi/pi-coding-agent/workspace-tree";
const tempDirs: string[] = [];
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-workspace-tree-"));
tempDirs.push(dir);
return dir;
}
async function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): Promise<void> {
await Bun.write(filePath, content);
const mtime = new Date(mtimeMs);
await fs.utimes(filePath, mtime, mtime);
}
async function touchDirWithMtime(dirPath: string, mtimeMs: number): Promise<void> {
await fs.mkdir(dirPath, { recursive: true });
const mtime = new Date(mtimeMs);
await fs.utimes(dirPath, mtime, mtime);
}
function lineIndex(rendered: string, needle: string): number {
return rendered.split("\n").findIndex(line => line.includes(needle));
}
describe("buildWorkspaceTree", () => {
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
});
it("sorts files and directories together by modification time", async () => {
const cwd = await makeTempDir();
const base = Date.now() - 60_000;
const recentDir = path.join(cwd, "recent-dir");
const staleDir = path.join(cwd, "stale-dir");
await touchDirWithMtime(recentDir, base + 3_000);
await touchDirWithMtime(staleDir, base);
await writeFileWithMtime(path.join(cwd, "newest.txt"), "newest", base + 4_000);
await writeFileWithMtime(path.join(cwd, "old.txt"), "old", base + 2_000);
await touchDirWithMtime(recentDir, base + 3_000);
await touchDirWithMtime(staleDir, base);
const tree = await buildWorkspaceTree(cwd);
expect(lineIndex(tree.rendered, "newest.txt")).toBeGreaterThan(-1);
expect(lineIndex(tree.rendered, "recent-dir/")).toBeGreaterThan(lineIndex(tree.rendered, "newest.txt"));
expect(lineIndex(tree.rendered, "old.txt")).toBeGreaterThan(lineIndex(tree.rendered, "recent-dir/"));
expect(lineIndex(tree.rendered, "stale-dir/")).toBeGreaterThan(lineIndex(tree.rendered, "old.txt"));
});
it("keeps the newest entries, a truncation marker, and the oldest entry per directory", async () => {
const cwd = await makeTempDir();
const base = Date.now() - 60_000;
for (let i = 0; i < 13; i += 1) {
const ageRank = String(i).padStart(2, "0");
await writeFileWithMtime(path.join(cwd, `file-${ageRank}.txt`), ageRank, base + (13 - i) * 1_000);
}
const tree = await buildWorkspaceTree(cwd);
expect(tree.truncated).toBe(true);
expect(tree.rendered).toContain("… 1 more");
expect(tree.rendered).toContain("file-00.txt");
expect(tree.rendered).toContain("file-10.txt");
expect(tree.rendered).not.toContain("file-11.txt");
expect(tree.rendered).toContain("file-12.txt");
expect(lineIndex(tree.rendered, "… 1 more")).toBeLessThan(lineIndex(tree.rendered, "file-12.txt"));
});
it("enforces the depth cap and skips hidden, excluded, and gitignored paths", async () => {
const cwd = await makeTempDir();
await Bun.write(path.join(cwd, ".gitignore"), "ignored.txt\nignored-dir/\na/b/ignored-nested.txt\n");
await writeFileWithMtime(path.join(cwd, "kept.txt"), "kept", Date.now() - 1_000);
await writeFileWithMtime(path.join(cwd, "ignored.txt"), "ignored", Date.now() - 1_000);
await touchDirWithMtime(path.join(cwd, "ignored-dir"), Date.now() - 1_000);
await writeFileWithMtime(path.join(cwd, "ignored-dir", "child.txt"), "ignored", Date.now() - 1_000);
await touchDirWithMtime(path.join(cwd, "node_modules"), Date.now() - 1_000);
await writeFileWithMtime(path.join(cwd, "node_modules", "pkg.js"), "ignored", Date.now() - 1_000);
await touchDirWithMtime(path.join(cwd, ".git"), Date.now() - 1_000);
await writeFileWithMtime(path.join(cwd, ".git", "config"), "ignored", Date.now() - 1_000);
await touchDirWithMtime(path.join(cwd, "a", "b", "c", "d"), Date.now() - 1_000);
await writeFileWithMtime(path.join(cwd, "a", "b", "c", "d", "deep.txt"), "deep", Date.now() - 1_000);
await writeFileWithMtime(path.join(cwd, "a", "b", "ignored-nested.txt"), "ignored", Date.now() - 1_000);
const tree = await buildWorkspaceTree(cwd);
expect(tree.rendered).toContain("kept.txt");
expect(tree.rendered).toContain("c/");
expect(tree.rendered).not.toContain("ignored.txt");
expect(tree.rendered).not.toContain("ignored-dir");
expect(tree.rendered).not.toContain("ignored-nested.txt");
expect(tree.rendered).not.toContain("node_modules");
expect(tree.rendered).not.toContain(".git");
expect(tree.rendered).not.toContain("d/");
expect(tree.rendered).not.toContain("deep.txt");
});
it("caps the rendered tree at the hard line limit", async () => {
const cwd = await makeTempDir();
const base = Date.now() - 60_000;
for (let dirIndex = 0; dirIndex < 12; dirIndex += 1) {
const dirName = `dir-${String(dirIndex).padStart(2, "0")}`;
const dirPath = path.join(cwd, dirName);
await fs.mkdir(dirPath, { recursive: true });
for (let fileIndex = 0; fileIndex < 12; fileIndex += 1) {
const fileName = `file-${String(fileIndex).padStart(2, "0")}.txt`;
await writeFileWithMtime(path.join(dirPath, fileName), fileName, base + fileIndex);
}
await touchDirWithMtime(dirPath, base + dirIndex);
}
const tree = await buildWorkspaceTree(cwd);
const renderedLines = tree.rendered.split("\n");
expect(tree.truncated).toBe(true);
expect(tree.totalLines).toBeLessThanOrEqual(120);
expect(renderedLines.length).toBeLessThanOrEqual(120);
expect(tree.rendered).toContain("lines elided beyond depth/cap");
});
});