Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 66 additions & 15 deletions packages/cli/src/templates/pi/extensions/trellis/index.ts.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface PiToolResult {
details?: unknown;
}
interface PiExtensionContext {
cwd?: string;
hasUI?: boolean;
model?: {
provider?: string;
Expand Down Expand Up @@ -964,12 +965,34 @@ function readJsonlEntries(basePath: string, jsonlPath: string): JsonlEntry[] {
function findRoot(start: string): string {
let c = resolve(start);
while (true) {
if (existsSync(join(c, ".trellis")) || existsSync(join(c, ".pi"))) return c;
// Only a directory with `.trellis/` is a Trellis project root. A bare
// `.pi` can be pi's global config (`~/.pi`) or an unrelated project, so
// accepting it here made root resolution stop too early (e.g. on `~`).
// Also reject a regular file named `.trellis` — the marker must be a
// directory.
const marker = join(c, ".trellis");
if (existsSync(marker) && statSync(marker).isDirectory()) return c;
const p = dirname(c);
if (p === c) return resolve(start);
c = p;
}
}
// Resolve the project root from the session working directory when available
// (pi's ExtensionContext.cwd), falling back to the pi host process cwd.
// process.cwd() is the host's launch directory and can differ from the
// session cwd (pi-web / RPC / multi-project hosts), which made .pi/agents
// lookups fail or resolve to the wrong project.
function resolveRoot(ctx?: PiExtensionContext): string {
return findRoot(ctx?.cwd ?? process.cwd());
Comment on lines +985 to +986

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add regression coverage for session-root resolution

When Pi runs through pi-web/RPC with process.cwd() pointing at one project and ctx.cwd at another, this branch is the entire fix, yet no test constructs that split: the existing Pi tests evaluate the extension with the host cwd already set to the Trellis root and pass contexts without cwd. The repository's unit-test convention requires regression coverage for bug fixes, so reverting to the process cwd or reintroducing the bare-.pi stop would leave every test green while breaking the reported configuration; add behavioral coverage using distinct host and session roots for context injection and subagent execution.

AGENTS.md reference: AGENTS.md:L8-L9

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — addressed in amended commit b69ad7e.

The existing root-switch regression covers context injection with host cwd != ctx.cwd. I also extended the spawned-child test so that:

  • the extension is loaded with a separate hostRoot;
  • the agent definition and fake Pi CLI exist only under sessionRoot;
  • trellis_subagent.execute receives ctx.cwd = sessionRoot;
  • the fake child records process.cwd(), which is asserted (via realpath) to equal sessionRoot.

This now covers both session-root agent-definition lookup and the child spawn cwd. Full suite: 76 files / 1709 tests passing.

}
// Cache key scoping per-session state by both the session key and the
// resolved project root. With dynamic root resolution a session can observe
// different ctx.cwd values over its lifetime (pi-web / RPC / project
// switching); keying caches by the session key alone would leak one
// project's startup/task context into another.
function cacheKey(k: string | null, ctx?: PiExtensionContext): string {
return `${k ?? "default"}::${resolveRoot(ctx)}`;
}
function splitFM(c: string) {
const m = c.replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
return m
Expand Down Expand Up @@ -1665,7 +1688,9 @@ export default function trellisExtension(pi: {
getThinkingLevel?: () => string;
}): void {
if (process.env.TRELLIS_SUBAGENT_CHILD === "1") return;
const root = findRoot(process.cwd());
// Process-level fallback; call sites with a session context re-resolve via
// resolveRoot(ctx) so the active project (session cwd) is used instead.
const root = resolveRoot();
const procKey = `pi_process_${hash([root, process.pid, Date.now(), randomBytes(8).toString("hex")].join(":"))}`;
let curKey: string | null = null;

Expand All @@ -1677,20 +1702,22 @@ export default function trellisExtension(pi: {

// Per-turn cache to avoid double-spawning python
let turnCache: {
key: string | null;
key: string;
ts: number;
wf: string;
ov: string;
} | null = null;
const getTurnCtx = (k: string | null) => {
const getTurnCtx = (k: string | null, ctx?: PiExtensionContext) => {
const now = Date.now();
if (turnCache && turnCache.key === k && now - turnCache.ts < 1500)
const ck = cacheKey(k, ctx);
if (turnCache && turnCache.key === ck && now - turnCache.ts < 1500)
return turnCache;
const r = resolveRoot(ctx);
turnCache = {
key: k,
key: ck,
ts: now,
wf: workflowBreadcrumb(root, k),
ov: sessionOverview(root, k),
wf: workflowBreadcrumb(r, k),
ov: sessionOverview(r, k),
};
return turnCache;
};
Expand All @@ -1702,18 +1729,27 @@ export default function trellisExtension(pi: {
const getStartupCtx = (
k: string | null,
turn: { ov: string },
ctx?: PiExtensionContext,
): string => {
const key = k ?? "default";
const key = cacheKey(k, ctx);
let startup = startupCtxCache.get(key);
if (startup === undefined) {
startup = buildStartupContext(root, k, turn.ov);
startup = buildStartupContext(resolveRoot(ctx), k, turn.ov);
startupCtxCache.set(key, startup);
}
return startup;
};
const taskCtxSnapshot = new Map<string, string>();
const lastSentTaskCtx = new Map<string, string>();
const lastSentRuntimeCtx = new Map<string, string>();
// Session-level "most recently persisted" project root. The root-scoped
// lastSent* maps suppress re-emission for an unchanged root, but when a
// session switches projects (A -> B -> A) the latest persisted update
// would otherwise stay B's — and its <trellis-task-context-update>
// explicitly supersedes the system-prompt context. Re-assert the current
// root's task/runtime context on every root transition so the agent never
// keeps following the previous project's instructions.
const lastPersistedRoot = new Map<string, string>();

// Toggle only the latest subagent native card; do not use Pi global tool expansion.
const toggleDetail = (ctx: PiExtensionContext) => {
Expand Down Expand Up @@ -1781,6 +1817,7 @@ export default function trellisExtension(pi: {
ctx?: PiExtensionContext,
) => {
activeSubagentToolCallId = id;
const root = resolveRoot(ctx);
const agentName = normalizeAgent(input.agent);
if (!isTrellisAgent(root, agentName)) {
return {
Expand Down Expand Up @@ -1931,10 +1968,11 @@ export default function trellisExtension(pi: {
});
pi.on?.("before_agent_start", (event, ctx) => {
const k = getKey(event, ctx);
const key = k ?? "default";
const key = cacheKey(k, ctx);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-emit the active root after switching projects

When one Pi session moves A → B → A and B's task changes while active, this composite key makes lastSentTaskCtx and lastSentRuntimeCtx remember separate values for each root. Returning to unchanged A therefore emits no new message, leaving B's later persisted <trellis-task-context-update>—which explicitly says it supersedes the system-prompt context—as the most recent update in history. The agent can consequently continue with B's task instructions after switching back to A; keep root-scoped immutable snapshots, but deduplicate persisted updates against the session's most recently emitted root/context so A is reasserted on every root transition.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed scenario — fixed in the amended commit ca6dce1.

Fix: added a session-level lastPersistedRoot map alongside the root-scoped lastSentTaskCtx / lastSentRuntimeCtx. On every before_agent_start, if the session previously persisted updates from a different root (switchedRoot), the current root's runtime context and <trellis-task-context-update> are re-emitted even when that root's on-disk content is unchanged. Root-scoped immutable snapshots (taskCtxSnapshot, startupCtxCache) are untouched, so the system-prompt prefix stays byte-stable per project. lastPersistedRoot only advances when an update is actually persisted.

Regression test: re-asserts the current root's task context when a session switches back — drives A → B → A with the same session key, asserts A's task-context update is re-emitted on return (and B's content is not), and that the first visit does not emit a redundant update.

Verification: full suite 76 files / 1709 tests passing (the single local failure is #512 env leak from PI_SESSION_ID set by the host Pi session; it passes with that var unset).

const cur = (event as { systemPrompt?: string }).systemPrompt ?? "";
const turn = getTurnCtx(k);
const startup = getStartupCtx(k, turn);
const root = resolveRoot(ctx);
const turn = getTurnCtx(k, ctx);
const startup = getStartupCtx(k, turn, ctx);
// Task context is snapshotted into systemPrompt once; later on-disk
// changes are delivered as persisted messages so the prefix stays stable.
const freshTaskCtx = buildContext(root, "trellis-implement", k);
Expand All @@ -1946,18 +1984,31 @@ export default function trellisExtension(pi: {
}
const updates: string[] = [];
const runtimeContext = [turn.wf, turn.ov].filter(Boolean).join("\n\n");
if (runtimeContext && runtimeContext !== lastSentRuntimeCtx.get(key)) {
// Re-assert the current root's context on project switches: when the
// session returns to an unchanged root, the root-scoped lastSent* maps
// alone would leave the previous project's persisted update as the most
// recent one in history.
const prevRoot = lastPersistedRoot.get(k ?? "default");
const switchedRoot = prevRoot !== undefined && prevRoot !== root;
if (
runtimeContext &&
(runtimeContext !== lastSentRuntimeCtx.get(key) || switchedRoot)
) {
lastSentRuntimeCtx.set(key, runtimeContext);
updates.push(runtimeContext);
}
if (freshTaskCtx !== lastSentTaskCtx.get(key)) {
if (
freshTaskCtx !== lastSentTaskCtx.get(key) ||
switchedRoot
) {
lastSentTaskCtx.set(key, freshTaskCtx);
updates.push(
"<trellis-task-context-update>\nTask context changed on disk. This supersedes the Trellis Task Context in the system prompt.\n\n" +
freshTaskCtx +
"\n</trellis-task-context-update>",
);
}
if (updates.length > 0) lastPersistedRoot.set(k ?? "default", root);
const content = updates.join("\n\n");
return {
message: content
Expand Down
137 changes: 129 additions & 8 deletions packages/cli/test/templates/pi.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createRequire } from "node:module";
import { existsSync, readFileSync, rmSync } from "node:fs";
import { existsSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand Down Expand Up @@ -574,6 +574,117 @@ describe("pi templates", () => {
}
});

it("scopes per-session caches by resolved root when ctx.cwd changes", () => {
const root1 = createMinimalTrellisRoot();
const root2 = createMinimalTrellisRoot();
const sessionsDir1 = join(root1, ".trellis", ".runtime", "sessions");
const sessionsDir2 = join(root2, ".trellis", ".runtime", "sessions");
const taskDir1 = join(root1, ".trellis", "tasks", "shared-task");
const taskDir2 = join(root2, ".trellis", "tasks", "shared-task");
mkdirSync(sessionsDir1, { recursive: true });
mkdirSync(sessionsDir2, { recursive: true });
mkdirSync(taskDir1, { recursive: true });
mkdirSync(taskDir2, { recursive: true });
writeFileSync(join(taskDir1, "prd.md"), "ROOT ONE PRD CONTENT");
writeFileSync(join(taskDir2, "prd.md"), "ROOT TWO PRD CONTENT");
const sessionRef = JSON.stringify({ current_task: "tasks/shared-task" });
writeFileSync(join(sessionsDir1, "pi_shared-session.json"), sessionRef);
writeFileSync(join(sessionsDir2, "pi_shared-session.json"), sessionRef);

try {
const { trellisExtension } = loadExtensionInternals();
const handlers = new Map<
string,
(event: unknown, ctx?: unknown) => unknown
>();
trellisExtension({
registerTool: vi.fn(),
registerShortcut: vi.fn(),
on(event, handler) {
handlers.set(event, handler);
},
});
const fire = (cwd: string) =>
handlers.get("before_agent_start")?.(
{ type: "before_agent_start", systemPrompt: "BASE" },
{ cwd, sessionManager: { getSessionId: () => "shared-session" } },
) as { systemPrompt?: string; message?: { content?: string } };

const first = fire(root1);
expect(first.systemPrompt).toContain("ROOT ONE PRD CONTENT");

// Same session key but a different session cwd must NOT reuse the
// first project's cached startup/task context.
const second = fire(root2);
expect(second.systemPrompt).toContain("ROOT TWO PRD CONTENT");
expect(second.systemPrompt).not.toContain("ROOT ONE PRD CONTENT");
} finally {
rmSync(root1, { recursive: true, force: true });
rmSync(root2, { recursive: true, force: true });
}
});

it("re-asserts the current root's task context when a session switches back", () => {
const root1 = createMinimalTrellisRoot();
const root2 = createMinimalTrellisRoot();
const sessionsDir1 = join(root1, ".trellis", ".runtime", "sessions");
const sessionsDir2 = join(root2, ".trellis", ".runtime", "sessions");
const taskDir1 = join(root1, ".trellis", "tasks", "shared-task");
const taskDir2 = join(root2, ".trellis", "tasks", "shared-task");
mkdirSync(sessionsDir1, { recursive: true });
mkdirSync(sessionsDir2, { recursive: true });
mkdirSync(taskDir1, { recursive: true });
mkdirSync(taskDir2, { recursive: true });
writeFileSync(join(taskDir1, "prd.md"), "ROOT ONE PRD CONTENT");
writeFileSync(join(taskDir2, "prd.md"), "ROOT TWO PRD CONTENT");
const sessionRef = JSON.stringify({ current_task: "tasks/shared-task" });
writeFileSync(join(sessionsDir1, "pi_shared-session.json"), sessionRef);
writeFileSync(join(sessionsDir2, "pi_shared-session.json"), sessionRef);

try {
const { trellisExtension } = loadExtensionInternals();
const handlers = new Map<
string,
(event: unknown, ctx?: unknown) => unknown
>();
trellisExtension({
registerTool: vi.fn(),
registerShortcut: vi.fn(),
on(event, handler) {
handlers.set(event, handler);
},
});
const fire = (cwd: string) =>
handlers.get("before_agent_start")?.(
{ type: "before_agent_start", systemPrompt: "BASE" },
{ cwd, sessionManager: { getSessionId: () => "shared-session" } },
) as { systemPrompt?: string; message?: { content?: string } };

// A: first visit seeds the snapshot into the system prompt; the
// persisted history carries only the runtime context.
const a1 = fire(root1);
expect(a1.message?.content ?? "").not.toContain(
"<trellis-task-context-update>",
);

// A -> B: the switch re-asserts B's task context as a persisted
// update (it supersedes the system-prompt context).
const b = fire(root2);
expect(b.message?.content).toContain("<trellis-task-context-update>");
expect(b.message?.content).toContain("ROOT TWO PRD CONTENT");

// B -> A with unchanged A on disk: A's task context must be re-emitted
// so the most recent persisted update belongs to A, not B.
const a2 = fire(root1);
expect(a2.message?.content).toContain("<trellis-task-context-update>");
expect(a2.message?.content).toContain("ROOT ONE PRD CONTENT");
expect(a2.message?.content).not.toContain("ROOT TWO PRD CONTENT");
} finally {
rmSync(root1, { recursive: true, force: true });
rmSync(root2, { recursive: true, force: true });
}
});

it("extension tool_result handler marks failed/cancelled subagent runs as errors", () => {
const extension = getExtensionTemplate();

Expand Down Expand Up @@ -793,10 +904,12 @@ fallbackModels:
});

it("passes the invoking Pi model to the spawned child process", async () => {
const root = createMinimalTrellisRoot();
const agentDir = join(root, ".pi", "agents");
const fakeCli = join(root, "fake-pi.cjs");
const capturedArgs = join(root, "child-args.json");
const hostRoot = createMinimalTrellisRoot();
const sessionRoot = createMinimalTrellisRoot();
const agentDir = join(sessionRoot, ".pi", "agents");
const fakeCli = join(sessionRoot, "fake-pi.cjs");
const capturedArgs = join(sessionRoot, "child-args.json");
const capturedCwd = join(sessionRoot, "child-cwd.txt");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
join(agentDir, "trellis-implement.md"),
Expand All @@ -807,13 +920,14 @@ fallbackModels:
[
'const { writeFileSync } = require("node:fs");',
`writeFileSync(${JSON.stringify(capturedArgs)}, JSON.stringify(process.argv.slice(2)));`,
`writeFileSync(${JSON.stringify(capturedCwd)}, process.cwd());`,
'process.stdout.write(JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "fake child ok" }] } }) + "\\n");',
"",
].join("\n"),
);

try {
const { trellisExtension } = loadExtensionInternals(root, {
const { trellisExtension } = loadExtensionInternals(hostRoot, {
TRELLIS_PI_CLI_JS: fakeCli,
});
let registeredTool: RegisteredPiTool | undefined;
Expand All @@ -832,10 +946,16 @@ fallbackModels:
{ agent: "trellis-implement", prompt: "Implement the task" },
undefined,
undefined,
{ model: { provider: "openai-proxy", id: "gpt-5.6-sol" } },
{
cwd: sessionRoot,
model: { provider: "openai-proxy", id: "gpt-5.6-sol" },
},
);

expect(result.content[0]?.text).toBe("fake child ok");
expect(realpathSync(readFileSync(capturedCwd, "utf-8"))).toBe(
realpathSync(sessionRoot),
);
expect(JSON.parse(readFileSync(capturedArgs, "utf-8"))).toEqual([
"--mode",
"json",
Expand All @@ -845,7 +965,8 @@ fallbackModels:
"openai-proxy/gpt-5.6-sol:xhigh",
]);
} finally {
rmSync(root, { recursive: true, force: true });
rmSync(hostRoot, { recursive: true, force: true });
rmSync(sessionRoot, { recursive: true, force: true });
}
});

Expand Down
Loading