Merge PR #3156: fix(advisor): surface nested repo context (@oldschoola)
# Conflicts: # packages/coding-agent/src/modes/components/status-line/component.ts # packages/coding-agent/src/sdk.ts # packages/coding-agent/src/system-prompt.ts
This commit is contained in:
@@ -361,6 +361,10 @@
|
||||
- Removed `/debug dump-next-request` command
|
||||
- Removed Wafer Pass from CLI credential help; Wafer Serverless remains available.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed advisor and status-line context when the session cwd is a parent of a single child git repo, so nested-repo work is surfaced before missing parent-cwd paths are treated as destroyed. ([#3130](https://github.com/can1357/oh-my-pi/issues/3130))
|
||||
|
||||
## [16.1.8] - 2026-06-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
|
||||
import { expandAtImports } from "../discovery/at-imports";
|
||||
import activeRepoWatchdogTemplate from "../prompts/advisor/active-repo-watchdog.md" with { type: "text" };
|
||||
import type { ActiveRepoContext } from "../utils/active-repo-context";
|
||||
import { repo } from "../utils/git";
|
||||
|
||||
function normalizePromptPath(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
export function formatActiveRepoWatchdogPrompt(activeRepoContext: ActiveRepoContext): string {
|
||||
return prompt
|
||||
.render(activeRepoWatchdogTemplate, {
|
||||
relativeRepoRoot: normalizePromptPath(activeRepoContext.relativeRepoRoot),
|
||||
})
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and load WATCHDOG.md files walking up from cwd, project .omp folder, and user agent dir.
|
||||
* Returns formatted watchdog file blocks ready to be appended to the advisor system prompt.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { settings } from "../../../config/settings";
|
||||
import type { AgentSession } from "../../../session/agent-session";
|
||||
import type { OAuthAccountIdentity } from "../../../session/auth-storage";
|
||||
import { limitMatchesActiveAccount } from "../../../slash-commands/helpers/active-oauth-account";
|
||||
import { type ActiveRepoContext, resolveActiveRepoContextSync } from "../../../utils/active-repo-context";
|
||||
import * as git from "../../../utils/git";
|
||||
import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
|
||||
import { sanitizeStatusText } from "../../shared";
|
||||
@@ -155,6 +156,12 @@ interface ContextUsageMemo {
|
||||
skillsRef: readonly any[] | undefined;
|
||||
}
|
||||
|
||||
interface ActiveRepoCache {
|
||||
projectDir: string;
|
||||
activeRepo: ActiveRepoContext | null;
|
||||
effectiveGitCwd: string;
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGES: readonly AgentMessage[] = [];
|
||||
const STATUS_USAGE_START_DELAY_MS = 0;
|
||||
const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000;
|
||||
@@ -196,17 +203,20 @@ export class StatusLineComponent implements Component {
|
||||
#goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
|
||||
#collabStatus: CollabStatus | null = null;
|
||||
#focusedAgentId: string | undefined;
|
||||
#activeRepoCache: ActiveRepoCache | undefined;
|
||||
|
||||
// Git status caching (1s TTL)
|
||||
#cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
||||
#cachedGitStatusCwd: string | undefined = undefined;
|
||||
#gitStatusLastFetch = 0;
|
||||
#gitStatusInFlight = false;
|
||||
#gitStatusInFlightCwd: string | undefined = undefined;
|
||||
|
||||
// PR lookup caching (invalidated on branch/repo context changes)
|
||||
#cachedPr: { number: number; url: string } | null | undefined = undefined;
|
||||
#cachedPrContext: PrCacheContext | undefined = undefined;
|
||||
#prLookupInFlight = false;
|
||||
#defaultBranch?: string;
|
||||
#defaultBranchCwd: string | undefined = undefined;
|
||||
#lastTokensPerSecond: number | null = null;
|
||||
#lastTokensPerSecondTimestamp: number | null = null;
|
||||
|
||||
@@ -249,6 +259,18 @@ export class StatusLineComponent implements Component {
|
||||
);
|
||||
}
|
||||
|
||||
#resolveActiveRepoCache(): ActiveRepoCache {
|
||||
const projectDir = getProjectDir();
|
||||
if (this.#activeRepoCache?.projectDir === projectDir) {
|
||||
return this.#activeRepoCache;
|
||||
}
|
||||
|
||||
const activeRepo = resolveActiveRepoContextSync(projectDir);
|
||||
const effectiveGitCwd = activeRepo?.repoRoot ?? projectDir;
|
||||
this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd };
|
||||
return this.#activeRepoCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive
|
||||
* from it. `focusedAgentId` is the focused subagent id while the view is proxied, undefined for main.
|
||||
@@ -335,7 +357,8 @@ export class StatusLineComponent implements Component {
|
||||
return;
|
||||
}
|
||||
|
||||
const repository = git.repo.resolveSync(getProjectDir());
|
||||
const { effectiveGitCwd } = this.#resolveActiveRepoCache();
|
||||
const repository = git.repo.resolveSync(effectiveGitCwd);
|
||||
if (!repository) return;
|
||||
|
||||
const watchPath = git.repo.isReftableSync(repository)
|
||||
@@ -390,17 +413,17 @@ export class StatusLineComponent implements Component {
|
||||
this.#cachedBranchCwd = undefined;
|
||||
this.#cachedPrContext = undefined;
|
||||
}
|
||||
#getCurrentBranch(): string | null {
|
||||
#getCurrentBranch(effectiveGitCwd?: string): string | null {
|
||||
if (!this.#gitEnabled()) return null;
|
||||
|
||||
const cwd = getProjectDir();
|
||||
if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === cwd) {
|
||||
const gitCwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
|
||||
if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === gitCwd) {
|
||||
return this.#cachedBranch;
|
||||
}
|
||||
|
||||
const head = git.head.resolveSync(cwd);
|
||||
const head = git.head.resolveSync(gitCwd);
|
||||
const gitHeadPath = head?.headPath ?? null;
|
||||
this.#cachedBranchCwd = cwd;
|
||||
this.#cachedBranchCwd = gitCwd;
|
||||
this.#cachedBranchRepoId = gitHeadPath;
|
||||
if (!head) {
|
||||
this.#cachedBranch = null;
|
||||
@@ -412,12 +435,18 @@ export class StatusLineComponent implements Component {
|
||||
return this.#cachedBranch ?? null;
|
||||
}
|
||||
|
||||
#isDefaultBranch(branch: string): boolean {
|
||||
#isDefaultBranch(branch: string, effectiveGitCwd: string): boolean {
|
||||
if (this.#defaultBranchCwd !== effectiveGitCwd) {
|
||||
this.#defaultBranch = undefined;
|
||||
this.#defaultBranchCwd = effectiveGitCwd;
|
||||
}
|
||||
|
||||
if (this.#defaultBranch === undefined) {
|
||||
this.#defaultBranch = "main";
|
||||
const lookupCwd = effectiveGitCwd;
|
||||
(async () => {
|
||||
const resolved = await git.branch.default(getProjectDir());
|
||||
if (this.#disposed) return;
|
||||
const resolved = await git.branch.default(lookupCwd);
|
||||
if (this.#disposed || this.#defaultBranchCwd !== lookupCwd) return;
|
||||
if (resolved) {
|
||||
this.#defaultBranch = resolved;
|
||||
if (this.#onBranchChange) {
|
||||
@@ -429,32 +458,43 @@ export class StatusLineComponent implements Component {
|
||||
return branch === this.#defaultBranch;
|
||||
}
|
||||
|
||||
#getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
|
||||
#getGitStatus(effectiveGitCwd?: string): { staged: number; unstaged: number; untracked: number } | null {
|
||||
if (!this.#gitEnabled()) return null;
|
||||
if (this.#gitStatusInFlight || Date.now() - this.#gitStatusLastFetch < 1000) {
|
||||
|
||||
const gitCwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
|
||||
if (this.#gitStatusInFlightCwd !== undefined) {
|
||||
return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
|
||||
}
|
||||
if (this.#cachedGitStatusCwd === gitCwd && Date.now() - this.#gitStatusLastFetch < 1000) {
|
||||
return this.#cachedGitStatus;
|
||||
}
|
||||
|
||||
this.#gitStatusInFlight = true;
|
||||
this.#gitStatusInFlightCwd = gitCwd;
|
||||
|
||||
(async () => {
|
||||
let nextStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
||||
try {
|
||||
this.#cachedGitStatus = await git.status.summary(getProjectDir());
|
||||
nextStatus = await git.status.summary(gitCwd);
|
||||
} catch {
|
||||
this.#cachedGitStatus = null;
|
||||
nextStatus = null;
|
||||
} finally {
|
||||
this.#gitStatusLastFetch = Date.now();
|
||||
this.#gitStatusInFlight = false;
|
||||
if (this.#gitStatusInFlightCwd === gitCwd) {
|
||||
this.#cachedGitStatus = nextStatus;
|
||||
this.#cachedGitStatusCwd = gitCwd;
|
||||
this.#gitStatusLastFetch = Date.now();
|
||||
this.#gitStatusInFlightCwd = undefined;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return this.#cachedGitStatus;
|
||||
return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
|
||||
}
|
||||
|
||||
#lookupPr(): { number: number; url: string } | null {
|
||||
#lookupPr(effectiveGitCwd?: string): { number: number; url: string } | null {
|
||||
if (!this.#gitEnabled()) return null;
|
||||
|
||||
const branch = this.#getCurrentBranch();
|
||||
const gitCwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
|
||||
const branch = this.#getCurrentBranch(gitCwd);
|
||||
const currentContext = branch ? createPrCacheContext(branch, this.#cachedBranchRepoId ?? null) : null;
|
||||
|
||||
if (canReuseCachedPr(this.#cachedPr, this.#cachedPrContext, currentContext)) {
|
||||
@@ -463,13 +503,20 @@ export class StatusLineComponent implements Component {
|
||||
|
||||
const stalePr = this.#cachedPr;
|
||||
|
||||
// Don't look up if no branch, detached HEAD, default branch, or already in flight
|
||||
if (!branch || branch === "detached" || this.#isDefaultBranch(branch) || this.#prLookupInFlight) {
|
||||
if (!branch) {
|
||||
this.#cachedPr = null;
|
||||
this.#cachedPrContext = undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't look up if detached, default branch, or already in flight.
|
||||
if (branch === "detached" || this.#isDefaultBranch(branch, gitCwd) || this.#prLookupInFlight) {
|
||||
return stalePr ?? null;
|
||||
}
|
||||
|
||||
this.#prLookupInFlight = true;
|
||||
const lookupContext = currentContext;
|
||||
const lookupCwd = gitCwd;
|
||||
|
||||
// Fire async lookup, keep stale value visible until resolved
|
||||
(async () => {
|
||||
@@ -486,7 +533,7 @@ export class StatusLineComponent implements Component {
|
||||
};
|
||||
try {
|
||||
// Requires `gh repo set-default` to be configured; fails gracefully if not
|
||||
const result = await $`gh pr view --json number,url`.quiet().nothrow();
|
||||
const result = await $`gh pr view --json number,url`.cwd(lookupCwd).quiet().nothrow();
|
||||
if (this.#disposed) return;
|
||||
if (result.exitCode !== 0) {
|
||||
setCachedPr(null);
|
||||
@@ -795,13 +842,14 @@ export class StatusLineComponent implements Component {
|
||||
contextPercent = collabState.contextUsage.percent ?? contextPercent;
|
||||
}
|
||||
|
||||
const gitBranch = includeGit || includePr ? this.#getCurrentBranch() : null;
|
||||
const gitStatus = includeGit ? this.#getGitStatus() : null;
|
||||
const gitPr = includePr ? this.#lookupPr() : null;
|
||||
|
||||
const activeRepoCache = this.#resolveActiveRepoCache();
|
||||
const gitBranch = includeGit || includePr ? this.#getCurrentBranch(activeRepoCache.effectiveGitCwd) : null;
|
||||
const gitStatus = includeGit ? this.#getGitStatus(activeRepoCache.effectiveGitCwd) : null;
|
||||
const gitPr = includePr ? this.#lookupPr(activeRepoCache.effectiveGitCwd) : null;
|
||||
return {
|
||||
session: this.session,
|
||||
focusedAgentId: this.#focusedAgentId,
|
||||
activeRepo: activeRepoCache.activeRepo,
|
||||
width,
|
||||
options: segmentOptions ?? {},
|
||||
planMode: this.#planModeStatus,
|
||||
|
||||
@@ -202,7 +202,7 @@ const pathSegment: StatusLineSegment = {
|
||||
render(ctx) {
|
||||
const opts = ctx.options.path ?? {};
|
||||
|
||||
const projectDir = getProjectDir();
|
||||
const projectDir = ctx.activeRepo?.cwd ?? getProjectDir();
|
||||
const { scratch, relative } = classifyProjectDir(projectDir);
|
||||
let pwd = projectDir;
|
||||
|
||||
@@ -213,6 +213,9 @@ const pathSegment: StatusLineSegment = {
|
||||
pwd = stripDisplayRoot(pwd);
|
||||
}
|
||||
}
|
||||
if (ctx.activeRepo) {
|
||||
pwd = `${pwd} ↳ ${ctx.activeRepo.relativeRepoRoot}`;
|
||||
}
|
||||
if (opts.abbreviate !== false) {
|
||||
pwd = shortenPath(pwd);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CollabSessionState } from "../../../collab/protocol";
|
||||
import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../../config/settings-schema";
|
||||
import type { AgentSession } from "../../../session/agent-session";
|
||||
import type { ActiveRepoContext } from "../../../utils/active-repo-context";
|
||||
|
||||
export type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle };
|
||||
|
||||
@@ -47,6 +48,7 @@ export interface SegmentContext {
|
||||
session: AgentSession;
|
||||
/** Focused subagent id while the view is proxied at its session, undefined otherwise. */
|
||||
focusedAgentId?: string | undefined;
|
||||
activeRepo: ActiveRepoContext | null;
|
||||
width: number;
|
||||
options: StatusLineSegmentOptions;
|
||||
planMode: {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Especially pay attention to:
|
||||
<attention>
|
||||
The session cwd is outside git, and exactly one direct child git repository was detected at `{{relativeRepoRoot}}`.
|
||||
|
||||
Paths under `{{relativeRepoRoot}}/` are the active project. Do not claim work is missing, destroyed, or absent at the parent cwd until you have checked under `{{relativeRepoRoot}}/`.
|
||||
</attention>
|
||||
@@ -0,0 +1,4 @@
|
||||
<active-repo-context>
|
||||
The session cwd is outside git. Exactly one direct child git repository was detected at `{{relativeRepoRoot}}`.
|
||||
Paths under `{{relativeRepoRoot}}/` are the active project for this session. Parent-cwd misses are inconclusive until checking under `{{relativeRepoRoot}}/`.
|
||||
</active-repo-context>
|
||||
@@ -25,7 +25,7 @@ import { FALLBACK_DIALECT, preferredDialect } from "@oh-my-pi/pi-catalog/identit
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { $env, $flag, getAgentDir, getProjectDir, logger, postmortem, prompt, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
||||
import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles } from "./advisor";
|
||||
import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles, formatActiveRepoWatchdogPrompt } from "./advisor";
|
||||
import { type AsyncJob, AsyncJobManager } from "./async";
|
||||
import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller";
|
||||
import { loadCapability } from "./capability";
|
||||
@@ -189,6 +189,7 @@ import { getImageGenTools } from "./tools/image-gen";
|
||||
import { wrapToolWithMetaNotice } from "./tools/output-meta";
|
||||
import { queueResolveHandler } from "./tools/resolve";
|
||||
import { ttsTool } from "./tools/tts";
|
||||
import { resolveActiveRepoContext } from "./utils/active-repo-context";
|
||||
import { EventBus } from "./utils/event-bus";
|
||||
import { buildNamedToolChoice } from "./utils/tool-choice";
|
||||
import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
|
||||
@@ -1146,6 +1147,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
? Promise.resolve(options.contextFiles)
|
||||
: logger.time("discoverContextFiles", discoverContextFiles, cwd, agentDir);
|
||||
contextFilesPromise.catch(() => {});
|
||||
const activeRepoContextPromise = logger.time("resolveActiveRepoContext", async () => {
|
||||
try {
|
||||
return await resolveActiveRepoContext(cwd);
|
||||
} catch (err) {
|
||||
logger.debug("Failed to resolve active repo context", { err: String(err) });
|
||||
return null;
|
||||
}
|
||||
});
|
||||
activeRepoContextPromise.catch(() => {});
|
||||
const watchdogFilesPromise = logger.time("discoverWatchdogFiles", () => discoverWatchdogFiles(cwd, agentDir));
|
||||
watchdogFilesPromise.catch(() => {});
|
||||
const promptTemplatesPromise = options.promptTemplates
|
||||
@@ -1393,10 +1403,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const [contextFiles, resolvedWorkspaceTree, watchdogFiles] = await Promise.all([
|
||||
const [contextFiles, resolvedWorkspaceTree, watchdogFiles, activeRepoContext] = await Promise.all([
|
||||
contextFilesPromise,
|
||||
raceWithDeadline("buildWorkspaceTree", workspaceTreePromise),
|
||||
watchdogFilesPromise,
|
||||
activeRepoContextPromise,
|
||||
]);
|
||||
|
||||
let agent: Agent;
|
||||
@@ -2268,6 +2279,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined,
|
||||
personality: agentKind === "sub" ? "none" : settings.get("personality"),
|
||||
renderMermaid: settings.get("tui.renderMermaid"),
|
||||
activeRepoContext,
|
||||
});
|
||||
|
||||
if (options.systemPrompt === undefined) {
|
||||
@@ -2661,10 +2673,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
.filter((tool): tool is Tool => tool != null)
|
||||
.map(wrapToolWithMetaNotice);
|
||||
|
||||
let advisorWatchdogPrompt: string | undefined;
|
||||
if (watchdogFiles && watchdogFiles.length > 0) {
|
||||
advisorWatchdogPrompt = watchdogFiles.join("\n\n");
|
||||
const advisorWatchdogPrompts = [...watchdogFiles];
|
||||
if (activeRepoContext) {
|
||||
advisorWatchdogPrompts.push(formatActiveRepoWatchdogPrompt(activeRepoContext));
|
||||
}
|
||||
const advisorWatchdogPrompt = advisorWatchdogPrompts.length > 0 ? advisorWatchdogPrompts.join("\n\n") : undefined;
|
||||
// Owned only when this session created the manager; subagents receive a
|
||||
// parent's manager via `options.mcpManager` and MUST NOT disconnect it.
|
||||
const ownedMcpManager = options.mcpManager ? undefined : mcpManager;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile
|
||||
import { expandAtImports } from "./discovery/at-imports";
|
||||
import { loadSkills, type Skill } from "./extensibility/skills";
|
||||
import { hasObsidian } from "./internal-urls/vault-protocol";
|
||||
import activeRepoContextTemplate from "./prompts/system/active-repo-context.md" with { type: "text" };
|
||||
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
|
||||
import defaultPersonality from "./prompts/system/personalities/default.md" with { type: "text" };
|
||||
import friendlyPersonality from "./prompts/system/personalities/friendly.md" with { type: "text" };
|
||||
@@ -23,6 +24,7 @@ import pragmaticPersonality from "./prompts/system/personalities/pragmatic.md" w
|
||||
import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
|
||||
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
|
||||
import { shortenPath } from "./tools/render-utils";
|
||||
import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context";
|
||||
import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
|
||||
|
||||
/** Bundled personality specs, keyed by the `personality` setting value. */
|
||||
@@ -90,6 +92,19 @@ function firstNonEmpty(...values: (string | undefined | null)[]): string | null
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePromptPath(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function renderActiveRepoContextPrompt(activeRepoContext: ActiveRepoContext | null): string {
|
||||
if (!activeRepoContext) return "";
|
||||
return prompt
|
||||
.render(activeRepoContextTemplate, {
|
||||
relativeRepoRoot: normalizePromptPath(activeRepoContext.relativeRepoRoot),
|
||||
})
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseWmicTable(output: string, header: string): string | null {
|
||||
const lines = output
|
||||
.split("\n")
|
||||
@@ -425,6 +440,8 @@ export interface BuildSystemPromptOptions {
|
||||
includeWorkspaceTree?: boolean;
|
||||
/** Whether Mermaid fenced blocks render as terminal ASCII diagrams. Default: true */
|
||||
renderMermaid?: boolean;
|
||||
/** Pre-resolved nested active repo context. Undefined resolves from cwd. */
|
||||
activeRepoContext?: ActiveRepoContext | null;
|
||||
}
|
||||
|
||||
/** Result of building provider-facing system prompt messages. */
|
||||
@@ -467,6 +484,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
personality = "default",
|
||||
includeWorkspaceTree = false,
|
||||
renderMermaid = true,
|
||||
activeRepoContext: providedActiveRepoContext,
|
||||
} = options;
|
||||
const inlineToolDescriptors = providedInlineToolDescriptors ?? false;
|
||||
const resolvedCwd = cwd ?? getProjectDir();
|
||||
@@ -484,6 +502,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
totalLines: 0,
|
||||
agentsMdFiles: [],
|
||||
} satisfies WorkspaceTree,
|
||||
activeRepoContext: null as ActiveRepoContext | null,
|
||||
};
|
||||
|
||||
const deadline = Bun.sleep(SYSTEM_PROMPT_PREP_TIMEOUT_MS).then(() => "__timeout__" as const);
|
||||
@@ -546,34 +565,42 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
: skillsSettings?.enabled !== false
|
||||
? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills)
|
||||
: Promise.resolve([]);
|
||||
const activeRepoContextPromise =
|
||||
providedActiveRepoContext !== undefined
|
||||
? Promise.resolve(providedActiveRepoContext)
|
||||
: logger.time("resolveActiveRepoContext", () => resolveActiveRepoContext(resolvedCwd));
|
||||
|
||||
const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles, skills, workspaceTree] =
|
||||
await Promise.all([
|
||||
withDeadline(
|
||||
"customPrompt",
|
||||
providedResolvedCustomPrompt !== undefined
|
||||
? Promise.resolve(providedResolvedCustomPrompt)
|
||||
: resolvePromptInput(customPrompt, "system prompt"),
|
||||
prepDefaults.resolvedCustomPrompt,
|
||||
),
|
||||
withDeadline(
|
||||
"appendSystemPrompt",
|
||||
providedResolvedAppendPrompt !== undefined
|
||||
? Promise.resolve(providedResolvedAppendPrompt)
|
||||
: resolvePromptInput(appendSystemPrompt, "append system prompt"),
|
||||
prepDefaults.resolvedAppendPrompt,
|
||||
),
|
||||
withDeadline(
|
||||
"loadSystemPromptFiles",
|
||||
systemPromptCustomizationPromise,
|
||||
prepDefaults.systemPromptCustomization,
|
||||
),
|
||||
withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then(
|
||||
dedupeExactContextFiles,
|
||||
),
|
||||
withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
|
||||
withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
|
||||
]);
|
||||
const [
|
||||
resolvedCustomPrompt,
|
||||
resolvedAppendPrompt,
|
||||
systemPromptCustomization,
|
||||
contextFiles,
|
||||
skills,
|
||||
workspaceTree,
|
||||
activeRepoContext,
|
||||
] = await Promise.all([
|
||||
withDeadline(
|
||||
"customPrompt",
|
||||
providedResolvedCustomPrompt !== undefined
|
||||
? Promise.resolve(providedResolvedCustomPrompt)
|
||||
: resolvePromptInput(customPrompt, "system prompt"),
|
||||
prepDefaults.resolvedCustomPrompt,
|
||||
),
|
||||
withDeadline(
|
||||
"appendSystemPrompt",
|
||||
providedResolvedAppendPrompt !== undefined
|
||||
? Promise.resolve(providedResolvedAppendPrompt)
|
||||
: resolvePromptInput(appendSystemPrompt, "append system prompt"),
|
||||
prepDefaults.resolvedAppendPrompt,
|
||||
),
|
||||
withDeadline("loadSystemPromptFiles", systemPromptCustomizationPromise, prepDefaults.systemPromptCustomization),
|
||||
withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then(
|
||||
dedupeExactContextFiles,
|
||||
),
|
||||
withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
|
||||
withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
|
||||
withDeadline("resolveActiveRepoContext", activeRepoContextPromise, prepDefaults.activeRepoContext),
|
||||
]);
|
||||
const agentsMdFiles = Array.from(new Set(workspaceTree.agentsMdFiles)).sort().slice(0, AGENTS_MD_LIMIT);
|
||||
|
||||
if (timedOut.length > 0) {
|
||||
@@ -598,7 +625,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const dateTime = date;
|
||||
const promptCwd = shortenPath(resolvedCwd.replace(/\\/g, "/"));
|
||||
const promptCwd = shortenPath(normalizePromptPath(resolvedCwd));
|
||||
const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext);
|
||||
|
||||
// Build tool metadata for system prompt rendering.
|
||||
// Priority: explicit list > tools map > conservative SDK fallback.
|
||||
@@ -697,6 +725,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
if (projectPrompt) {
|
||||
systemPrompt.push(projectPrompt);
|
||||
}
|
||||
if (activeRepoContextPrompt) {
|
||||
systemPrompt.push(activeRepoContextPrompt);
|
||||
}
|
||||
|
||||
return { systemPrompt };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as fsPromises from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { type GitRepository, repo } from "./git";
|
||||
|
||||
export interface ActiveRepoContext {
|
||||
cwd: string;
|
||||
repoRoot: string;
|
||||
relativeRepoRoot: string;
|
||||
source: "single-direct-child-repo";
|
||||
}
|
||||
|
||||
function compareEntryNames(left: fs.Dirent, right: fs.Dirent): number {
|
||||
if (left.name < right.name) return -1;
|
||||
if (left.name > right.name) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildContext(cwd: string, repoRoot: string): ActiveRepoContext {
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
const resolvedRepoRoot = path.resolve(repoRoot);
|
||||
return {
|
||||
cwd: resolvedCwd,
|
||||
repoRoot: resolvedRepoRoot,
|
||||
relativeRepoRoot: path.relative(resolvedCwd, resolvedRepoRoot),
|
||||
source: "single-direct-child-repo",
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRepository(cwd: string): Promise<GitRepository | null> {
|
||||
try {
|
||||
return await repo.resolve(cwd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRepositorySync(cwd: string): GitRepository | null {
|
||||
try {
|
||||
return repo.resolveSync(cwd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readDirectChildren(cwd: string): Promise<fs.Dirent[]> {
|
||||
try {
|
||||
const entries = await fsPromises.readdir(cwd, { withFileTypes: true });
|
||||
entries.sort(compareEntryNames);
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readDirectChildrenSync(cwd: string): fs.Dirent[] {
|
||||
try {
|
||||
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
||||
entries.sort(compareEntryNames);
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectChildDirectory(cwd: string, entry: fs.Dirent): Promise<string | null> {
|
||||
const childPath = path.join(cwd, entry.name);
|
||||
if (entry.isDirectory()) return childPath;
|
||||
if (!entry.isSymbolicLink()) return null;
|
||||
try {
|
||||
const stat = await fsPromises.stat(childPath);
|
||||
return stat.isDirectory() ? childPath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDirectChildDirectorySync(cwd: string, entry: fs.Dirent): string | null {
|
||||
const childPath = path.join(cwd, entry.name);
|
||||
if (entry.isDirectory()) return childPath;
|
||||
if (!entry.isSymbolicLink()) return null;
|
||||
try {
|
||||
const stat = fs.statSync(childPath);
|
||||
return stat.isDirectory() ? childPath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function findSingleDirectChildRepo(cwd: string): Promise<ActiveRepoContext | null> {
|
||||
let context: ActiveRepoContext | null = null;
|
||||
for (const entry of await readDirectChildren(cwd)) {
|
||||
const childPath = await resolveDirectChildDirectory(cwd, entry);
|
||||
if (!childPath) continue;
|
||||
const repository = await resolveRepository(childPath);
|
||||
if (!repository || path.resolve(repository.repoRoot) !== path.resolve(childPath)) continue;
|
||||
if (context) return null;
|
||||
context = buildContext(cwd, repository.repoRoot);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function findSingleDirectChildRepoSync(cwd: string): ActiveRepoContext | null {
|
||||
let context: ActiveRepoContext | null = null;
|
||||
for (const entry of readDirectChildrenSync(cwd)) {
|
||||
const childPath = resolveDirectChildDirectorySync(cwd, entry);
|
||||
if (!childPath) continue;
|
||||
const repository = resolveRepositorySync(childPath);
|
||||
if (!repository || path.resolve(repository.repoRoot) !== path.resolve(childPath)) continue;
|
||||
if (context) return null;
|
||||
context = buildContext(cwd, repository.repoRoot);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function resolveActiveRepoContext(cwd: string): Promise<ActiveRepoContext | null> {
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
if (await resolveRepository(resolvedCwd)) return null;
|
||||
return findSingleDirectChildRepo(resolvedCwd);
|
||||
}
|
||||
|
||||
export function resolveActiveRepoContextSync(cwd: string): ActiveRepoContext | null {
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
if (resolveRepositorySync(resolvedCwd)) return null;
|
||||
return findSingleDirectChildRepoSync(resolvedCwd);
|
||||
}
|
||||
@@ -20,6 +20,62 @@ describe("advisor watchdog prompt discovery", () => {
|
||||
}
|
||||
});
|
||||
|
||||
async function withAdvisorHistory(
|
||||
tempDir: TempDir,
|
||||
cwd: string,
|
||||
run: (dump: string) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const authStorage = await AuthStorage.create(tempDir.join("testauth.db"));
|
||||
let session: AgentSession | undefined;
|
||||
try {
|
||||
authStorage.setRuntimeApiKey("openai", "test-key");
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
const sessionManager = SessionManager.create(cwd, tempDir.join("sessions"));
|
||||
const result = await createAgentSession({
|
||||
cwd,
|
||||
agentDir: tempDir.path(),
|
||||
sessionManager,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settings: (() => {
|
||||
const s = Settings.isolated({
|
||||
"async.enabled": false,
|
||||
"advisor.enabled": true,
|
||||
});
|
||||
s.setModelRole("advisor", "openai/gpt-4o-mini");
|
||||
return s;
|
||||
})(),
|
||||
model: getBundledModel("openai", "gpt-4o-mini"),
|
||||
disableExtensionDiscovery: true,
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
workspaceTree: {
|
||||
rootPath: cwd,
|
||||
rendered: "",
|
||||
truncated: false,
|
||||
totalLines: 0,
|
||||
agentsMdFiles: [],
|
||||
},
|
||||
promptTemplates: [],
|
||||
slashCommands: [],
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
});
|
||||
session = result.session;
|
||||
|
||||
expect(session.isAdvisorActive()).toBe(true);
|
||||
const dump = session.formatAdvisorHistoryAsText();
|
||||
if (dump === null) throw new Error("Advisor history was not available.");
|
||||
await run(dump);
|
||||
} finally {
|
||||
try {
|
||||
await session?.dispose();
|
||||
} finally {
|
||||
authStorage.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("discovers and appends WATCHDOG.md to the advisor prompt", async () => {
|
||||
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
|
||||
tempDirs.push(tempDir);
|
||||
@@ -77,6 +133,39 @@ describe("advisor watchdog prompt discovery", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("adds built-in active child repo context to the advisor prompt", async () => {
|
||||
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
|
||||
tempDirs.push(tempDir);
|
||||
const cwd = tempDir.join("parent-cwd");
|
||||
fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true });
|
||||
const watchdogContent = "Parent watchdog remains before built-in active repo context.";
|
||||
fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8");
|
||||
|
||||
await withAdvisorHistory(tempDir, cwd, dump => {
|
||||
expect(dump).toContain("Especially pay attention to:");
|
||||
expect(dump).toContain("exactly one direct child git repository");
|
||||
expect(dump).toContain("`active-project`");
|
||||
expect(dump).toContain("Do not claim work is missing, destroyed, or absent at the parent cwd");
|
||||
expect(dump).toContain(watchdogContent);
|
||||
expect(dump.indexOf(watchdogContent)).toBeLessThan(
|
||||
dump.indexOf("Do not claim work is missing, destroyed, or absent at the parent cwd"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("omits built-in active child repo context when multiple direct child repos exist", async () => {
|
||||
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
|
||||
tempDirs.push(tempDir);
|
||||
const cwd = tempDir.join("parent-cwd");
|
||||
fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(cwd, "second-project", ".git"), { recursive: true });
|
||||
|
||||
await withAdvisorHistory(tempDir, cwd, dump => {
|
||||
expect(dump).not.toContain("exactly one direct child git repository");
|
||||
expect(dump).not.toContain("Do not claim work is missing, destroyed, or absent at the parent cwd");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves nested folders and sorts by depth", async () => {
|
||||
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
import {
|
||||
type ActiveRepoContext,
|
||||
resolveActiveRepoContext,
|
||||
resolveActiveRepoContextSync,
|
||||
} from "@oh-my-pi/pi-coding-agent/utils/active-repo-context";
|
||||
|
||||
function createGitDirectory(repoRoot: string): void {
|
||||
const gitDir = path.join(repoRoot, ".git");
|
||||
fs.mkdirSync(gitDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(gitDir, "HEAD"), "ref: refs/heads/main\n", "utf8");
|
||||
}
|
||||
|
||||
function createLinkedWorktreeGitFile(worktreeRoot: string, gitDir: string, commonDir: string): void {
|
||||
fs.mkdirSync(worktreeRoot, { recursive: true });
|
||||
fs.mkdirSync(gitDir, { recursive: true });
|
||||
fs.mkdirSync(commonDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(gitDir, "HEAD"), "ref: refs/heads/main\n", "utf8");
|
||||
fs.writeFileSync(path.join(gitDir, "commondir"), `${path.relative(gitDir, commonDir)}\n`, "utf8");
|
||||
fs.writeFileSync(path.join(commonDir, "HEAD"), "ref: refs/heads/main\n", "utf8");
|
||||
fs.writeFileSync(path.join(worktreeRoot, ".git"), `gitdir: ${path.relative(worktreeRoot, gitDir)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function expectResolvers(cwd: string, expected: ActiveRepoContext | null): Promise<void> {
|
||||
expect(resolveActiveRepoContextSync(cwd)).toEqual(expected);
|
||||
expect(await resolveActiveRepoContext(cwd)).toEqual(expected);
|
||||
}
|
||||
|
||||
describe("resolveActiveRepoContext", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omp-active-repo-context-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
it("returns null when cwd is already inside a repository", async () => {
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const cwd = path.join(repoRoot, "nested");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
createGitDirectory(repoRoot);
|
||||
|
||||
await expectResolvers(cwd, null);
|
||||
});
|
||||
|
||||
it("returns null when no direct child repository exists", async () => {
|
||||
const cwd = path.join(tempRoot, "workspace");
|
||||
fs.mkdirSync(path.join(cwd, "not-a-repo"), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, "plain-file.txt"), "ignored\n", "utf8");
|
||||
|
||||
await expectResolvers(cwd, null);
|
||||
});
|
||||
|
||||
it("returns the sole direct child repository context", async () => {
|
||||
const cwd = path.join(tempRoot, "workspace");
|
||||
const repoRoot = path.join(cwd, "repo");
|
||||
fs.mkdirSync(path.join(cwd, "not-a-repo"), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, "plain-file.txt"), "ignored\n", "utf8");
|
||||
createGitDirectory(repoRoot);
|
||||
|
||||
const expected = {
|
||||
cwd,
|
||||
repoRoot,
|
||||
relativeRepoRoot: "repo",
|
||||
source: "single-direct-child-repo",
|
||||
} satisfies ActiveRepoContext;
|
||||
await expectResolvers(cwd, expected);
|
||||
});
|
||||
|
||||
it("treats a direct child symlink to a repository directory as that child", async () => {
|
||||
const cwd = path.join(tempRoot, "workspace");
|
||||
const targetRoot = path.join(tempRoot, "target-repo");
|
||||
const repoRoot = path.join(cwd, "linked-repo");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
createGitDirectory(targetRoot);
|
||||
fs.symlinkSync(targetRoot, repoRoot, "junction");
|
||||
|
||||
const expected = {
|
||||
cwd,
|
||||
repoRoot,
|
||||
relativeRepoRoot: "linked-repo",
|
||||
source: "single-direct-child-repo",
|
||||
} satisfies ActiveRepoContext;
|
||||
await expectResolvers(cwd, expected);
|
||||
});
|
||||
|
||||
it("returns null when two direct child repositories exist", async () => {
|
||||
const cwd = path.join(tempRoot, "workspace");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
createGitDirectory(path.join(cwd, "alpha"));
|
||||
createGitDirectory(path.join(cwd, "beta"));
|
||||
|
||||
await expectResolvers(cwd, null);
|
||||
});
|
||||
|
||||
it("accepts a direct child linked-worktree .git file", async () => {
|
||||
const cwd = path.join(tempRoot, "workspace");
|
||||
const repoRoot = path.join(cwd, "worktree");
|
||||
const gitDir = path.join(tempRoot, "admin", "worktrees", "worktree");
|
||||
const commonDir = path.join(tempRoot, "admin", "common.git");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
createLinkedWorktreeGitFile(repoRoot, gitDir, commonDir);
|
||||
|
||||
const expected = {
|
||||
cwd,
|
||||
repoRoot,
|
||||
relativeRepoRoot: "worktree",
|
||||
source: "single-direct-child-repo",
|
||||
} satisfies ActiveRepoContext;
|
||||
await expectResolvers(cwd, expected);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ function createCtx(usage: Partial<SegmentContext["usageStats"]>): SegmentContext
|
||||
autoCompactEnabled: false,
|
||||
subagentCount: 0,
|
||||
sessionStartTime: Date.now(),
|
||||
activeRepo: null,
|
||||
git: {
|
||||
branch: null,
|
||||
status: null,
|
||||
|
||||
@@ -37,6 +37,7 @@ function createModelContext(advisorActive: boolean): SegmentContext {
|
||||
autoCompactEnabled: false,
|
||||
subagentCount: 0,
|
||||
sessionStartTime: Date.now(),
|
||||
activeRepo: null,
|
||||
git: { branch: null, status: null, pr: null },
|
||||
usage: null,
|
||||
};
|
||||
|
||||
@@ -61,6 +61,7 @@ function createCtx(overrides?: { pathMaxLength?: number; branch?: string | null
|
||||
autoCompactEnabled: false,
|
||||
subagentCount: 0,
|
||||
sessionStartTime: Date.now(),
|
||||
activeRepo: null,
|
||||
git: {
|
||||
branch: overrides?.branch ?? null,
|
||||
status: null,
|
||||
|
||||
@@ -47,6 +47,7 @@ function createPathContext(): SegmentContext {
|
||||
autoCompactEnabled: false,
|
||||
subagentCount: 0,
|
||||
sessionStartTime: Date.now(),
|
||||
activeRepo: null,
|
||||
git: {
|
||||
branch: null,
|
||||
status: null,
|
||||
@@ -60,6 +61,14 @@ afterEach(() => {
|
||||
setProjectDir(originalProjectDir);
|
||||
});
|
||||
|
||||
function expectContentToContainPath(content: string, expected: string): void {
|
||||
if (process.platform === "win32") {
|
||||
expect(content.toLowerCase()).toContain(expected.toLowerCase());
|
||||
return;
|
||||
}
|
||||
expect(content).toContain(expected);
|
||||
}
|
||||
|
||||
describe("status line path segment", () => {
|
||||
it("strips the Projects root for symlink-equivalent aliases", () => {
|
||||
if (process.platform === "win32") return;
|
||||
@@ -87,6 +96,7 @@ describe("status line path segment", () => {
|
||||
expect(rendered.content).not.toContain("home-link");
|
||||
expect(rendered.content).not.toContain(`${path.sep}Projects${path.sep}`);
|
||||
} finally {
|
||||
setProjectDir(originalProjectDir);
|
||||
fs.rmSync(aliasRoot, { recursive: true, force: true });
|
||||
fs.rmSync(realProjectDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -102,9 +112,10 @@ describe("status line path segment", () => {
|
||||
expect(rendered.content).toContain(theme.icon.scratchFolder);
|
||||
expect(rendered.content).not.toContain(theme.icon.folder);
|
||||
// Display is just the scratch-relative tail — no leading tmpdir, no ancestor segments.
|
||||
expect(rendered.content).toContain(path.basename(scratchDir));
|
||||
expectContentToContainPath(rendered.content, path.basename(getProjectDir()));
|
||||
expect(rendered.content).not.toContain(os.tmpdir());
|
||||
} finally {
|
||||
setProjectDir(originalProjectDir);
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -117,11 +128,12 @@ describe("status line path segment", () => {
|
||||
setProjectDir(nested);
|
||||
|
||||
const rendered = renderSegment("path", createPathContext());
|
||||
const tail = `${path.basename(scratchDir)}${path.sep}sub${path.sep}deep`;
|
||||
const tail = `${path.basename(path.dirname(path.dirname(getProjectDir())))}${path.sep}sub${path.sep}deep`;
|
||||
expect(rendered.content).toContain(theme.icon.scratchFolder);
|
||||
expect(rendered.content).toContain(tail);
|
||||
expectContentToContainPath(rendered.content, tail);
|
||||
expect(rendered.content).not.toContain(os.tmpdir());
|
||||
} finally {
|
||||
setProjectDir(originalProjectDir);
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -138,6 +150,7 @@ describe("status line path segment", () => {
|
||||
expect(rendered.content).toContain(theme.icon.folder);
|
||||
expect(rendered.content).not.toContain(theme.icon.scratchFolder);
|
||||
} finally {
|
||||
setProjectDir(originalProjectDir);
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -154,7 +167,33 @@ describe("status line path segment", () => {
|
||||
expect(rendered.content).toContain(theme.icon.folder);
|
||||
expect(rendered.content).not.toContain(theme.icon.scratchFolder);
|
||||
} finally {
|
||||
setProjectDir(originalProjectDir);
|
||||
fs.rmSync(realProjectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the active nested repo suffix after the parent cwd", () => {
|
||||
const parentDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-status-line-parent-"));
|
||||
const repoDir = path.join(parentDir, "pr-workspace");
|
||||
fs.mkdirSync(repoDir);
|
||||
try {
|
||||
setProjectDir(parentDir);
|
||||
const ctx = createPathContext();
|
||||
ctx.activeRepo = {
|
||||
cwd: parentDir,
|
||||
repoRoot: repoDir,
|
||||
relativeRepoRoot: "pr-workspace",
|
||||
source: "single-direct-child-repo",
|
||||
};
|
||||
|
||||
const rendered = renderSegment("path", ctx);
|
||||
const expected = `${path.basename(getProjectDir())} ↳ pr-workspace`;
|
||||
expect(rendered.visible).toBe(true);
|
||||
expectContentToContainPath(rendered.content, expected);
|
||||
expect(rendered.content).not.toContain(os.tmpdir());
|
||||
} finally {
|
||||
setProjectDir(originalProjectDir);
|
||||
fs.rmSync(parentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,16 +132,47 @@ describe("SYSTEM.md prompt assembly", () => {
|
||||
});
|
||||
|
||||
const promptText = systemPrompt.join("\n\n");
|
||||
const normalizedProjectDir = projectDir.replace(/\\/g, "/");
|
||||
const appendMatches = promptText.match(new RegExp(escapeRegExp(appendPrompt), "g")) ?? [];
|
||||
expect(systemPrompt).toHaveLength(2);
|
||||
expect(promptText).toContain("CLI custom prompt");
|
||||
expect(promptText).toContain("<workspace-tree>");
|
||||
expect(promptText).toContain("<dir-context>");
|
||||
expect(promptText).toContain(`current working directory is '${projectDir}'`);
|
||||
expect(promptText).toMatch(
|
||||
new RegExp(
|
||||
`^Today is [^,\\n]+, and the current working directory is '${escapeRegExp(normalizedProjectDir)}'\\.$`,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
expect(appendMatches).toHaveLength(1);
|
||||
expect(promptText).not.toContain("Discovered project SYSTEM prompt");
|
||||
});
|
||||
|
||||
it("renders active child repo context in the main system prompt", async () => {
|
||||
const parentDir = path.join(tempDir, "parent-cwd");
|
||||
fs.mkdirSync(path.join(parentDir, "active-project", ".git"), { recursive: true });
|
||||
|
||||
const { systemPrompt } = await buildSystemPrompt({
|
||||
cwd: parentDir,
|
||||
contextFiles: [],
|
||||
skills: [],
|
||||
rules: [],
|
||||
toolNames: [],
|
||||
workspaceTree: {
|
||||
rootPath: parentDir,
|
||||
rendered: "",
|
||||
truncated: false,
|
||||
totalLines: 0,
|
||||
agentsMdFiles: [],
|
||||
},
|
||||
});
|
||||
|
||||
const promptText = systemPrompt.join("\n\n");
|
||||
expect(promptText).toContain("<active-repo-context>");
|
||||
expect(promptText).toContain("Exactly one direct child git repository was detected at `active-project`.");
|
||||
expect(promptText).toContain("Paths under `active-project/` are the active project");
|
||||
});
|
||||
|
||||
it("prefers project SYSTEM.md over user SYSTEM.md", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
fs.mkdirSync(path.join(projectDir, ".omp"), { recursive: true });
|
||||
|
||||
Reference in New Issue
Block a user