Add dynamic multi-root workspace context (#2569)
A session now carries an ordered list of workspace directories beyond cwd, managed live from the terminal. New /add-dir, /remove-dir, and /dirs slash commands let you add and remove folders mid-session; the repeatable --add-dir CLI flag seeds them at launch, and the workspace.additionalDirectories setting persists defaults per project. Additional roots are persisted in the session header, survive reopen/fork/move, and are surfaced to the agent in the system prompt so it knows they exist and can read/grep/glob them by absolute path. Design aligns with the endorsed community implementation on feature/session-workspace. Co-authored-by: oh-my-pi <https://omp.sh>
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added dynamic multi-root workspace context (issue [#2569](https://github.com/can1357/oh-my-pi/issues/2569)): a session now carries an ordered list of workspace directories beyond `cwd`, managed live from the terminal. New `/add-dir <path>`, `/remove-dir <path>`, and `/dirs` slash commands let you add and remove folders mid-session; the repeatable `--add-dir <path>` CLI flag seeds them at launch, and the `workspace.additionalDirectories` setting persists defaults per project. Additional roots are persisted in the session header, survive reopen/fork/move, and are surfaced to the agent in the system prompt so it knows they exist and can `read`/`grep`/`glob` them by absolute path. Design aligns with the endorsed community implementation on `feature/session-workspace`.
|
||||
|
||||
## [17.0.7] - 2026-07-21
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -19,6 +19,8 @@ export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui";
|
||||
|
||||
export interface Args {
|
||||
cwd?: string;
|
||||
/** Workspace directories beyond cwd for this session (repeatable `--add-dir`). */
|
||||
addDir?: string[];
|
||||
profile?: string;
|
||||
alias?: string;
|
||||
allowHome?: boolean;
|
||||
|
||||
@@ -117,6 +117,9 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
|
||||
"--config": (result, value) => {
|
||||
result.config = [...(result.config ?? []), value];
|
||||
},
|
||||
"--add-dir": (result, value) => {
|
||||
result.addDir = [...(result.addDir ?? []), value];
|
||||
},
|
||||
"--mode": (result, value) => {
|
||||
if (value === "text" || value === "json" || value === "rpc" || value === "acp" || value === "rpc-ui") {
|
||||
result.mode = value;
|
||||
|
||||
@@ -83,6 +83,10 @@ export default class Index extends Command {
|
||||
description: "Load an extra config.yml-style overlay for this run (repeatable)",
|
||||
multiple: true,
|
||||
}),
|
||||
"add-dir": Flags.string({
|
||||
description: "Add a workspace directory beyond the working directory (repeatable)",
|
||||
multiple: true,
|
||||
}),
|
||||
print: Flags.boolean({
|
||||
char: "p",
|
||||
description: "Non-interactive mode: process prompt and exit",
|
||||
|
||||
@@ -1185,6 +1185,18 @@ export const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
|
||||
"workspace.additionalDirectories": {
|
||||
type: "array",
|
||||
default: [] as string[],
|
||||
ui: {
|
||||
tab: "model",
|
||||
group: "Context",
|
||||
label: "Additional Workspace Dirs",
|
||||
description:
|
||||
"Extra workspace directories added to every session as additional roots (multi-root workspace). Managed live via /add-dir and /remove-dir. Paths resolve relative to cwd; absolute paths recommended. The agent is told these roots exist and can read/grep/glob them.",
|
||||
},
|
||||
},
|
||||
|
||||
personality: {
|
||||
type: "enum",
|
||||
values: ["default", "friendly", "pragmatic", "none"] as const,
|
||||
|
||||
@@ -833,6 +833,9 @@ export async function buildSessionOptions(
|
||||
cwd: parsed.cwd ?? getProjectDir(),
|
||||
autoApprove: parsed.autoApprove ?? false,
|
||||
};
|
||||
if (parsed.addDir && parsed.addDir.length > 0) {
|
||||
options.additionalDirectories = parsed.addDir;
|
||||
}
|
||||
if (parsed.maxTime !== undefined) {
|
||||
options.deadline = Date.now() + parsed.maxTime * 1000;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,14 @@ Working directory layout (sorted by mtime, recent first; depth ≤ 3):
|
||||
</workspace-tree>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if additionalWorkspaceRoots.length}}
|
||||
<workspace-roots>
|
||||
This session also spans the additional directories below. This list is the CURRENT workspace state and supersedes any workspace change mentioned earlier in the conversation. Use absolute paths under these roots to `read`/`grep`/`glob`/`edit` them. Manage the set with `/add-dir` and `/remove-dir`; `/dirs` lists them.
|
||||
{{#each additionalWorkspaceRoots}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
</workspace-roots>
|
||||
{{/if}}
|
||||
Today is {{date}}, and the current working directory is '{{cwd}}'.
|
||||
|
||||
<critical>
|
||||
|
||||
@@ -371,6 +371,8 @@ function applyMCPEnvironment(result: { exaApiKeys: string[] }): void {
|
||||
export interface CreateAgentSessionOptions {
|
||||
/** Working directory for project-local discovery. Default: getProjectDir() */
|
||||
cwd?: string;
|
||||
/** Additional workspace directories beyond cwd (multi-root), absolute or cwd-relative. */
|
||||
additionalDirectories?: string[];
|
||||
/** Global config directory. Default: ~/.omp/agent */
|
||||
agentDir?: string;
|
||||
/** Spawns to allow. Default: "*" */
|
||||
@@ -1309,6 +1311,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
logger.time("sessionManager", () =>
|
||||
SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)),
|
||||
);
|
||||
if (options.additionalDirectories && options.additionalDirectories.length > 0) {
|
||||
sessionManager.setAdditionalDirectories(options.additionalDirectories);
|
||||
}
|
||||
const providerSessionId = options.providerSessionId ?? sessionManager.getSessionId();
|
||||
const forkCacheShapeChanged =
|
||||
options.model !== undefined ||
|
||||
@@ -2517,6 +2522,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
}
|
||||
const defaultPrompt = await buildSystemPromptInternal({
|
||||
cwd,
|
||||
additionalWorkspaceRoots: sessionManager.getAdditionalDirectories(),
|
||||
xdevTools: toolSession.xdevRegistry?.entries() ?? [],
|
||||
xdevDocs: toolSession.xdevRegistry?.docsAll() ?? "",
|
||||
autoQaEnabled: !restrictToolNames && isAutoQaEnabled(settings),
|
||||
|
||||
@@ -194,7 +194,7 @@ import {
|
||||
import { getKnownRoleIds, MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
|
||||
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
|
||||
import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier";
|
||||
import type { Settings, SkillsSettings } from "../config/settings";
|
||||
import type { SettingPath, Settings, SkillsSettings } from "../config/settings";
|
||||
import {
|
||||
getDefault,
|
||||
onAppendOnlyModeChanged,
|
||||
@@ -9984,6 +9984,9 @@ export class AgentSession {
|
||||
await this.sessionManager.flush();
|
||||
}
|
||||
await this.sessionManager.newSession(options);
|
||||
const configuredDirs =
|
||||
(this.settings.get("workspace.additionalDirectories" as SettingPath) as string[] | undefined) ?? [];
|
||||
if (configuredDirs.length > 0) this.sessionManager.setAdditionalDirectories(configuredDirs);
|
||||
this.#markBashSessionTransition(bashTransition);
|
||||
sessionTransitioned = true;
|
||||
} finally {
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface SessionHeader {
|
||||
titleSource?: SessionTitleSource;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
/**
|
||||
* Additional workspace directories beyond `cwd` (multi-root workspace).
|
||||
* Absolute, normalized, deduplicated. Absent on legacy single-cwd sessions.
|
||||
* See {@link SessionWorkspace} in `./session-workspace`.
|
||||
*/
|
||||
additionalDirectories?: string[];
|
||||
parentSession?: string;
|
||||
/** Provider prompt-cache identity inherited by exact-route full forks. */
|
||||
providerPromptCacheKey?: string;
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
type SessionStorageWriter,
|
||||
} from "./session-storage";
|
||||
import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot";
|
||||
import { additionalWorkspaceDirectories, normalizeSessionWorkspace, type SessionWorkspace } from "./session-workspace";
|
||||
|
||||
const JSONL_SUFFIX_LENGTH = ".jsonl".length;
|
||||
const DRAFT_ONLY_SESSION_MARKER = ".draft-only-session";
|
||||
@@ -380,6 +381,8 @@ interface DiskQueueOptions {
|
||||
*/
|
||||
export class SessionManager {
|
||||
#cwd: string;
|
||||
/** Additional workspace directories beyond cwd (multi-root). Normalized absolute, deduped, excludes cwd. */
|
||||
#additionalDirectories: string[] = [];
|
||||
#sessionDir: string;
|
||||
readonly #persist: boolean;
|
||||
readonly #storage: SessionStorage;
|
||||
@@ -1040,6 +1043,7 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
this.#applyEntries(header, fileEntries.slice(1) as SessionEntry[]);
|
||||
this.#additionalDirectories = header.additionalDirectories ?? [];
|
||||
this.#titleUpdatedAt = titleSlot?.updatedAt ?? header.timestamp;
|
||||
this.#hasTitleSlot = titleSlot !== undefined;
|
||||
this.#fileIsCurrent = true;
|
||||
@@ -1090,6 +1094,7 @@ export class SessionManager {
|
||||
titleSource: this.#header.titleSource ?? this.#titleSource,
|
||||
timestamp,
|
||||
cwd: this.#cwd,
|
||||
additionalDirectories: this.#additionalDirectories.length > 0 ? [...this.#additionalDirectories] : undefined,
|
||||
parentSession: parentSessionId,
|
||||
providerPromptCacheKey: this.#header.providerPromptCacheKey ?? parentSessionId,
|
||||
};
|
||||
@@ -1303,6 +1308,70 @@ export class SessionManager {
|
||||
return this.#cwd;
|
||||
}
|
||||
|
||||
/** Additional workspace directories beyond cwd (multi-root), absolute and normalized. */
|
||||
getAdditionalDirectories(): string[] {
|
||||
return [...this.#additionalDirectories];
|
||||
}
|
||||
|
||||
/** Full workspace as a {@link SessionWorkspace}: cwd first, then additional dirs. */
|
||||
getWorkspace(): SessionWorkspace {
|
||||
return { cwd: this.#cwd, directories: [this.#cwd, ...this.#additionalDirectories] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a workspace directory. Normalizes (relative to cwd), dedupes, rejects
|
||||
* the cwd itself, persists to the session header, and triggers an atomic
|
||||
* rewrite so the change survives a crash. Returns the resolved absolute
|
||||
* path or `null` when the directory was already present (no-op).
|
||||
*/
|
||||
async addWorkspaceDirectory(directory: string): Promise<string | null> {
|
||||
const resolved = path.resolve(this.#cwd, directory);
|
||||
if (resolved === path.resolve(this.#cwd)) {
|
||||
throw new Error("The current working directory is already the primary workspace root.");
|
||||
}
|
||||
if (this.#additionalDirectories.includes(resolved)) return null;
|
||||
this.#additionalDirectories = [...this.#additionalDirectories, resolved];
|
||||
this.#header.additionalDirectories = this.#additionalDirectories;
|
||||
if (this.#persist && this.#sessionFile) {
|
||||
this.#rewriteRequired = true;
|
||||
await this.#rewriteAtomically();
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a workspace directory by absolute or cwd-relative path. Persists
|
||||
* the trimmed header. Returns the resolved path that was removed, or
|
||||
* `null` when the directory was not an additional root (no-op).
|
||||
*/
|
||||
async removeWorkspaceDirectory(directory: string): Promise<string | null> {
|
||||
const resolved = path.isAbsolute(directory) ? directory : path.resolve(this.#cwd, directory);
|
||||
const idx = this.#additionalDirectories.findIndex(p => path.resolve(p) === resolved);
|
||||
if (idx === -1) return null;
|
||||
this.#additionalDirectories = this.#additionalDirectories.filter((_, i) => i !== idx);
|
||||
if (this.#additionalDirectories.length === 0) {
|
||||
this.#header.additionalDirectories = undefined;
|
||||
} else {
|
||||
this.#header.additionalDirectories = this.#additionalDirectories;
|
||||
}
|
||||
if (this.#persist && this.#sessionFile) {
|
||||
this.#rewriteRequired = true;
|
||||
await this.#rewriteAtomically();
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Seed additional directories from settings or a passed list (called at session creation). */
|
||||
setAdditionalDirectories(directories: string[]): void {
|
||||
const workspace = normalizeSessionWorkspace({ cwd: this.#cwd, directories });
|
||||
this.#additionalDirectories = additionalWorkspaceDirectories(workspace);
|
||||
if (this.#additionalDirectories.length > 0) {
|
||||
this.#header.additionalDirectories = this.#additionalDirectories;
|
||||
} else {
|
||||
this.#header.additionalDirectories = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getUsageStatistics(): UsageStatistics {
|
||||
return this.#index.usageSnapshot();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
/**
|
||||
* Filesystem workspace of a session: one current/default directory plus a
|
||||
* non-empty ordered list of workspace directories.
|
||||
*
|
||||
* `cwd` remains the default directory for relative-path resolution and
|
||||
* backward compatibility. `directories` always contains `cwd` first, followed
|
||||
* by any additional directories in their supplied order (deduplicated).
|
||||
* Directory order is stable but carries no semantic hierarchy.
|
||||
*
|
||||
* Workspace directories come from the platform (ACP/editor), CLI, or config —
|
||||
* never from filesystem walk-up discovery.
|
||||
*/
|
||||
export interface SessionWorkspace {
|
||||
/** Current/default directory for compatibility and relative path resolution. */
|
||||
cwd: string;
|
||||
/** Non-empty ordered list of absolute normalized directories; `cwd` is always first. */
|
||||
directories: string[];
|
||||
}
|
||||
|
||||
/** Expand a leading `~`/`~/` and resolve to an absolute path (relative input resolves against `base`). */
|
||||
export function normalizeWorkspaceDirectory(directory: string, base?: string): string {
|
||||
let expanded = directory;
|
||||
if (expanded === "~") {
|
||||
expanded = os.homedir();
|
||||
} else if (expanded.startsWith("~/") || expanded.startsWith(`~${path.sep}`)) {
|
||||
expanded = path.join(os.homedir(), expanded.slice(2));
|
||||
}
|
||||
return base ? path.resolve(base, expanded) : path.resolve(expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a normalized {@link SessionWorkspace} from a cwd and optional
|
||||
* additional directories. Additional entries are normalized (relative entries
|
||||
* resolve against the normalized cwd), deduplicated, and appended after `cwd`
|
||||
* preserving their supplied order.
|
||||
*/
|
||||
export function normalizeSessionWorkspace(args: { cwd: string; directories?: string[] }): SessionWorkspace {
|
||||
const cwd = normalizeWorkspaceDirectory(args.cwd);
|
||||
const directories = [cwd];
|
||||
for (const directory of args.directories ?? []) {
|
||||
const normalized = normalizeWorkspaceDirectory(directory, cwd);
|
||||
if (!directories.includes(normalized)) directories.push(normalized);
|
||||
}
|
||||
return { cwd, directories };
|
||||
}
|
||||
|
||||
/** The workspace directories beyond `cwd`, in order (ACP `additionalDirectories` shape). */
|
||||
export function additionalWorkspaceDirectories(workspace: SessionWorkspace): string[] {
|
||||
return workspace.directories.filter(directory => directory !== workspace.cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the workspace directory containing `filePath` (longest prefix wins, so
|
||||
* nested roots resolve to the deepest match), falling back to `fallback` when
|
||||
* no workspace directory contains it. Keeps per-file consumers (e.g. LSP root
|
||||
* selection) from collapsing unrelated repos into one parent tree.
|
||||
*/
|
||||
export function workspaceRootForPath(filePath: string, directories: string[] | undefined, fallback: string): string {
|
||||
let best = "";
|
||||
for (const directory of directories ?? []) {
|
||||
const prefixed = directory.endsWith(path.sep) ? directory : `${directory}${path.sep}`;
|
||||
if ((filePath === directory || filePath.startsWith(prefixed)) && directory.length > best.length) {
|
||||
best = directory;
|
||||
}
|
||||
}
|
||||
return best || fallback;
|
||||
}
|
||||
@@ -203,6 +203,14 @@ function parseShakeMode(args: string): ShakeMode | { error: string } {
|
||||
return { error: `Unknown /shake mode "${verb}". Use elide or images.` };
|
||||
}
|
||||
|
||||
/** Format the session's workspace directories (cwd + additional) for display. */
|
||||
function formatWorkspaceDirectories(runtime: SlashCommandRuntime, note?: string): string {
|
||||
const cwd = runtime.sessionManager.getCwd();
|
||||
const additional = runtime.sessionManager.getAdditionalDirectories();
|
||||
const lines = ["Workspace directories:", ` ${cwd} (working directory)`, ...additional.map(d => ` ${d}`)];
|
||||
return note ? `${note}\n${lines.join("\n")}` : lines.join("\n");
|
||||
}
|
||||
|
||||
const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
{
|
||||
name: "settings",
|
||||
@@ -1733,6 +1741,74 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
await runtime.ctx.handleMoveCommand(command.args || undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add-dir",
|
||||
description: "Add a workspace directory to this session (multi-root)",
|
||||
acpDescription: "Add a workspace directory to this session",
|
||||
inlineHint: "<path>",
|
||||
allowArgs: true,
|
||||
handle: async (command, runtime) => {
|
||||
if (runtime.session.isStreaming) return usage("Cannot add a directory while streaming.", runtime);
|
||||
if (!command.args) return usage(formatWorkspaceDirectories(runtime, "Usage: /add-dir <path>"), runtime);
|
||||
const resolved = resolveToCwd(command.args, runtime.cwd);
|
||||
try {
|
||||
const stat = await fs.stat(resolved);
|
||||
if (!stat.isDirectory()) return usage(`Not a directory: ${resolved}`, runtime);
|
||||
} catch {
|
||||
return usage(`Directory does not exist: ${resolved}`, runtime);
|
||||
}
|
||||
let added: string | null;
|
||||
try {
|
||||
added = await runtime.sessionManager.addWorkspaceDirectory(resolved);
|
||||
} catch (err) {
|
||||
return usage(errorMessage(err), runtime);
|
||||
}
|
||||
if (added === null) {
|
||||
await runtime.output(`Already in the workspace: ${resolved}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
await runtime.session.refreshBaseSystemPrompt();
|
||||
await runtime.output(formatWorkspaceDirectories(runtime, `Added ${added}.`));
|
||||
return commandConsumed();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove-dir",
|
||||
description: "Remove a workspace directory from this session",
|
||||
acpDescription: "Remove a workspace directory from this session",
|
||||
inlineHint: "<path>",
|
||||
allowArgs: true,
|
||||
handle: async (command, runtime) => {
|
||||
if (runtime.session.isStreaming) return usage("Cannot remove a directory while streaming.", runtime);
|
||||
if (!command.args) return usage("Usage: /remove-dir <path>", runtime);
|
||||
const resolved = resolveToCwd(command.args, runtime.cwd);
|
||||
if (resolved === path.resolve(runtime.cwd)) {
|
||||
return usage("Cannot remove the working directory; use /move to change it.", runtime);
|
||||
}
|
||||
let removed: string | null;
|
||||
try {
|
||||
removed = await runtime.sessionManager.removeWorkspaceDirectory(resolved);
|
||||
} catch (err) {
|
||||
return usage(errorMessage(err), runtime);
|
||||
}
|
||||
if (removed === null) {
|
||||
await runtime.output(`Not a workspace directory: ${resolved}`);
|
||||
return commandConsumed();
|
||||
}
|
||||
await runtime.session.refreshBaseSystemPrompt();
|
||||
await runtime.output(formatWorkspaceDirectories(runtime, `Removed ${removed}.`));
|
||||
return commandConsumed();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dirs",
|
||||
description: "List this session's workspace directories",
|
||||
acpDescription: "List this session's workspace directories",
|
||||
handle: async (_command, runtime) => {
|
||||
await runtime.output(formatWorkspaceDirectories(runtime));
|
||||
return commandConsumed();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exit",
|
||||
description: "Exit the application",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ToolExample, TSchema } from "@oh-my-pi/pi-ai";
|
||||
import { renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
|
||||
@@ -467,6 +468,8 @@ export interface BuildSystemPromptOptions {
|
||||
skillsSettings?: SkillsSettings;
|
||||
/** Working directory. Default: getProjectDir() */
|
||||
cwd?: string;
|
||||
/** Additional workspace directories beyond cwd (multi-root), absolute. Injected into the project prompt. */
|
||||
additionalWorkspaceRoots?: string[];
|
||||
/** Pre-loaded context files (skips discovery if provided). */
|
||||
contextFiles?: Array<{ path: string; content: string; depth?: number }>;
|
||||
/** Skills provided directly to system prompt construction. */
|
||||
@@ -536,6 +539,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
skillsSettings,
|
||||
toolNames: providedToolNames,
|
||||
cwd,
|
||||
additionalWorkspaceRoots = [],
|
||||
contextFiles: providedContextFiles,
|
||||
skills: providedSkills,
|
||||
rules,
|
||||
@@ -795,6 +799,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
date,
|
||||
dateTime,
|
||||
cwd: promptCwd,
|
||||
additionalWorkspaceRoots: additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)),
|
||||
model: includeModelInPrompt ? (model ?? "") : "",
|
||||
useCodexTaskPrompt: usesCodexTaskPrompt(model),
|
||||
personality: personality === "none" ? "" : PERSONALITY_SPECS[personality].trim(),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import {
|
||||
additionalWorkspaceDirectories,
|
||||
normalizeSessionWorkspace,
|
||||
workspaceRootForPath,
|
||||
} from "@oh-my-pi/pi-coding-agent/session/session-workspace";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
describe("normalizeSessionWorkspace", () => {
|
||||
it("places cwd first and dedupes additional directories", () => {
|
||||
const cwd = "/home/user/proj";
|
||||
const workspace = normalizeSessionWorkspace({ cwd, directories: ["/home/user/other", cwd, "/home/user/other"] });
|
||||
expect(workspace.cwd).toBe(path.resolve(cwd));
|
||||
expect(workspace.directories).toEqual([path.resolve(cwd), path.resolve("/home/user/other")]);
|
||||
});
|
||||
|
||||
it("resolves relative additional directories against the normalized cwd", () => {
|
||||
const workspace = normalizeSessionWorkspace({ cwd: "/home/user/proj", directories: ["../sibling"] });
|
||||
expect(workspace.directories).toEqual([path.resolve("/home/user/proj"), path.resolve("/home/user/sibling")]);
|
||||
});
|
||||
|
||||
it("expands ~ to home", () => {
|
||||
const workspace = normalizeSessionWorkspace({ cwd: "/tmp", directories: ["~/docs"] });
|
||||
expect(workspace.directories[1]).toBe(path.join(process.env.HOME ?? require("node:os").homedir(), "docs"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("additionalWorkspaceDirectories", () => {
|
||||
it("returns every directory except cwd", () => {
|
||||
const workspace = normalizeSessionWorkspace({ cwd: "/a", directories: ["/b", "/c"] });
|
||||
expect(additionalWorkspaceDirectories(workspace)).toEqual([path.resolve("/b"), path.resolve("/c")]);
|
||||
});
|
||||
|
||||
it("is empty for a single-root workspace", () => {
|
||||
const workspace = normalizeSessionWorkspace({ cwd: "/a" });
|
||||
expect(additionalWorkspaceDirectories(workspace)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceRootForPath", () => {
|
||||
it("picks the longest-prefix match", () => {
|
||||
const dirs = ["/repo", "/repo/packages/sub"];
|
||||
expect(workspaceRootForPath("/repo/packages/sub/file.ts", dirs, "/fallback")).toBe("/repo/packages/sub");
|
||||
expect(workspaceRootForPath("/repo/other/file.ts", dirs, "/fallback")).toBe("/repo");
|
||||
});
|
||||
|
||||
it("falls back when no root contains the path", () => {
|
||||
expect(workspaceRootForPath("/elsewhere/file.ts", ["/repo"], "/fallback")).toBe("/fallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionManager workspace directories", () => {
|
||||
it("starts with no additional directories", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getAdditionalDirectories()).toEqual([]);
|
||||
expect(session.getWorkspace().directories).toEqual([session.getCwd()]);
|
||||
});
|
||||
|
||||
it("seeds from setAdditionalDirectories and excludes cwd", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.setAdditionalDirectories(["/some/other", session.getCwd()]);
|
||||
// cwd is filtered out of the additional set.
|
||||
expect(session.getAdditionalDirectories()).toEqual(["/some/other"]);
|
||||
expect(session.getWorkspace().directories).toEqual([session.getCwd(), "/some/other"]);
|
||||
});
|
||||
|
||||
it("addWorkspaceDirectory rejects the cwd itself", async () => {
|
||||
const session = SessionManager.inMemory();
|
||||
await expect(session.addWorkspaceDirectory(session.getCwd())).rejects.toThrow(/primary workspace root/);
|
||||
});
|
||||
|
||||
it("addWorkspaceDirectory returns the resolved path and dedupes on repeat", async () => {
|
||||
const session = SessionManager.inMemory();
|
||||
const added = await session.addWorkspaceDirectory("/another/repo");
|
||||
expect(added).toBe(path.resolve("/another/repo"));
|
||||
expect(session.getAdditionalDirectories()).toEqual([path.resolve("/another/repo")]);
|
||||
|
||||
// Second add of the same path is a no-op.
|
||||
const second = await session.addWorkspaceDirectory("/another/repo");
|
||||
expect(second).toBeNull();
|
||||
expect(session.getAdditionalDirectories()).toEqual([path.resolve("/another/repo")]);
|
||||
});
|
||||
|
||||
it("removeWorkspaceDirectory removes a known root and returns null when absent", async () => {
|
||||
const session = SessionManager.inMemory();
|
||||
await session.addWorkspaceDirectory("/x");
|
||||
const removed = await session.removeWorkspaceDirectory("/x");
|
||||
expect(removed).toBe(path.resolve("/x"));
|
||||
expect(session.getAdditionalDirectories()).toEqual([]);
|
||||
|
||||
const again = await session.removeWorkspaceDirectory("/x");
|
||||
expect(again).toBeNull();
|
||||
});
|
||||
|
||||
it("persists additionalDirectories in the session header across reopen", async () => {
|
||||
using tempDir = TempDir.createSync("@pi-session-workspace-persist-");
|
||||
const session = SessionManager.create(tempDir.path(), tempDir.path());
|
||||
await session.addWorkspaceDirectory(path.join(tempDir.path(), "sibling"));
|
||||
// Materialize on disk so reopen reads the header.
|
||||
session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
await session.flush();
|
||||
|
||||
const file = session.getSessionFile();
|
||||
expect(file).toBeDefined();
|
||||
const reopened = await SessionManager.open(file!);
|
||||
expect(reopened.getAdditionalDirectories()).toEqual([path.join(tempDir.path(), "sibling")]);
|
||||
expect(reopened.getWorkspace().directories).toEqual([tempDir.path(), path.join(tempDir.path(), "sibling")]);
|
||||
});
|
||||
|
||||
it("clears the header field when the last additional directory is removed", async () => {
|
||||
using tempDir = TempDir.createSync("@pi-session-workspace-clear-");
|
||||
const session = SessionManager.create(tempDir.path(), tempDir.path());
|
||||
await session.addWorkspaceDirectory(path.join(tempDir.path(), "extra"));
|
||||
session.appendMessage({ role: "user", content: "hi", timestamp: 1 });
|
||||
await session.flush();
|
||||
|
||||
await session.removeWorkspaceDirectory(path.join(tempDir.path(), "extra"));
|
||||
const file = session.getSessionFile()!;
|
||||
const raw = fs.readFileSync(file, "utf8").split("\n")[0]!;
|
||||
const header = JSON.parse(raw);
|
||||
expect(header.additionalDirectories).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { createMockModel, registerMockApi } from "@oh-my-pi/pi-ai/providers/mock";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
registerMockApi();
|
||||
|
||||
/**
|
||||
* Contract: when `/add-dir` (or `addWorkspaceDirectory` + `refreshBaseSystemPrompt`)
|
||||
* runs mid-session, the rebuilt system prompt MUST list the newly-added directory
|
||||
* in its <workspace-roots> block. The sessionManager state updates immediately
|
||||
* (so `/dirs` reflects the add), but the system prompt is only re-read on the next
|
||||
* `refreshBaseSystemPrompt`; this test guards that refresh path end-to-end.
|
||||
*/
|
||||
describe("workspace directories in the system prompt", () => {
|
||||
it("adds a directory to the <workspace-roots> block after addWorkspaceDirectory + refreshBaseSystemPrompt", async () => {
|
||||
const dir = TempDir.createSync("@ws-prompt-add-");
|
||||
const auth = await AuthStorage.create(path.join(dir.path(), "auth.db"));
|
||||
try {
|
||||
auth.setRuntimeApiKey("mock", "test-key");
|
||||
const extraDir = path.join(dir.path(), "extra-root");
|
||||
fs.mkdirSync(extraDir, { recursive: true });
|
||||
const laterDir = path.join(dir.path(), "later-root");
|
||||
fs.mkdirSync(laterDir, { recursive: true });
|
||||
|
||||
const mockModel = createMockModel({ id: "text", handler: () => ({ content: ["ok"] }) });
|
||||
const settings = Settings.isolated({
|
||||
"compaction.enabled": false,
|
||||
"todo.enabled": false,
|
||||
"retry.enabled": false,
|
||||
});
|
||||
const sessionManager = SessionManager.inMemory(dir.path());
|
||||
const { session } = await createAgentSession({
|
||||
cwd: dir.path(),
|
||||
agentDir: dir.path(),
|
||||
additionalDirectories: [extraDir],
|
||||
authStorage: auth,
|
||||
modelRegistry: new ModelRegistry(auth, path.join(dir.path(), "models.yml")),
|
||||
model: mockModel,
|
||||
settings,
|
||||
sessionManager,
|
||||
disableExtensionDiscovery: true,
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
skills: [],
|
||||
rules: [],
|
||||
contextFiles: [],
|
||||
});
|
||||
try {
|
||||
// Sanity: the seed dir is present in the sessionManager state.
|
||||
expect(sessionManager.getAdditionalDirectories()).toEqual([extraDir]);
|
||||
|
||||
// Add a second directory live (as /add-dir does) and refresh the prompt.
|
||||
await sessionManager.addWorkspaceDirectory(laterDir);
|
||||
await session.refreshBaseSystemPrompt();
|
||||
|
||||
// sessionManager state now has both.
|
||||
expect(sessionManager.getAdditionalDirectories()).toEqual([extraDir, laterDir]);
|
||||
|
||||
// Send a prompt so we can inspect the system prompt the provider received.
|
||||
await session.prompt("noop");
|
||||
|
||||
const calls = mockModel.calls ?? [];
|
||||
const lastCall = calls.at(-1);
|
||||
const systemPrompt = (lastCall?.context?.systemPrompt as string[] | undefined)?.join("\n") ?? "";
|
||||
|
||||
// Both directories must appear in the <workspace-roots> block.
|
||||
expect(systemPrompt).toContain("<workspace-roots>");
|
||||
expect(systemPrompt).toContain(extraDir);
|
||||
expect(systemPrompt).toContain(laterDir);
|
||||
} finally {
|
||||
await session.dispose();
|
||||
}
|
||||
} finally {
|
||||
auth.close();
|
||||
dir.removeSync();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user