From 641f6ed95a61d21c87d1134c7d2b6cc2e4ae17e1 Mon Sep 17 00:00:00 2001 From: yeyaowei Date: Sat, 8 Aug 2026 22:35:39 +0800 Subject: [PATCH] fix(storage): resolve project paths from session cwd Use ExtensionContext.cwd for task storage, project configuration, and relative PI_TASKS overrides so long-lived hosts do not use their process startup directory. Related to #29 and #34. --- src/index.ts | 106 +++++++++++++-------- src/tasks-config.ts | 12 ++- src/ui/settings-menu.ts | 15 +-- test/agent-reattach.test.ts | 1 + test/auto-cascade.test.ts | 1 + test/helpers/mock-pi.ts | 8 +- test/stale-task-reminder.test.ts | 7 +- test/store-scope.test.ts | 39 ++++---- test/subagent-integration.test.ts | 149 +++++++++++++++++++++++++----- test/tasks-command.test.ts | 1 + test/tasks-config.test.ts | 3 +- 11 files changed, 248 insertions(+), 94 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6e97173..cc7cc7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,7 @@ import { resetCadenceState, } from "./reminder-cadence.js"; import { TaskStore } from "./task-store.js"; -import { loadTasksConfig } from "./tasks-config.js"; +import { loadGlobalTasksConfig, loadTasksConfig } from "./tasks-config.js"; import type { Task } from "./types.js"; import { openSettingsMenu } from "./ui/settings-menu.js"; import { TaskWidget, type UICtx } from "./ui/task-widget.js"; @@ -125,28 +125,38 @@ function buildSystemReminder(tasks: Task[]): string { } export default function (pi: ExtensionAPI) { - // Initialize store and config - const cfg = loadTasksConfig(); + // Project overrides require ExtensionContext.cwd, which is unavailable while + // the extension factory runs. Start with global defaults, then merge the + // active workspace's overrides on the first context-bearing event. + const cfg = loadGlobalTasksConfig(); const piTasks = process.env.PI_TASKS; - const taskScope = cfg.taskScope ?? "session"; - - /** Resolve the task store path from env/config (without session ID). */ - function resolveStorePath(sessionId?: string): string | undefined { - if (piTasks === "off") return undefined; - if (piTasks?.startsWith("/")) return piTasks; - if (piTasks?.startsWith(".")) return resolve(piTasks); - if (piTasks) return piTasks; - if (taskScope === "memory") return undefined; + let taskScope = cfg.taskScope ?? "session"; + + /** Resolve both the backing path and a stable identity for the active store. */ + function resolveStoreTarget(cwd?: string, sessionId?: string): { key: string; path?: string } { + if (piTasks === "off") return { key: "memory:env" }; + if (piTasks?.startsWith("/")) return { key: `path:${piTasks}`, path: piTasks }; + if (piTasks?.startsWith(".")) { + const path = cwd ? resolve(cwd, piTasks) : undefined; + return path ? { key: `path:${path}`, path } : { key: "pending:relative" }; + } + if (piTasks) return { key: `named:${piTasks}`, path: piTasks }; + if (taskScope === "memory") return { key: "memory:config" }; + if (!cwd) return { key: "pending:workspace" }; if (taskScope === "session" && sessionId) { - return join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`); + const path = join(cwd, ".pi", "tasks", `tasks-${sessionId}.json`); + return { key: `path:${path}`, path }; } - if (taskScope === "session") return undefined; // no session ID yet, start in-memory - return join(process.cwd(), ".pi", "tasks", "tasks.json"); + if (taskScope === "session") return { key: "pending:session" }; + const path = join(cwd, ".pi", "tasks", "tasks.json"); + return { key: `path:${path}`, path }; } - // For project scope (or env override), create store immediately. - // For session scope, start with in-memory and upgrade once we have the session ID. - let store = new TaskStore(resolveStorePath()); + // Project and relative paths need ExtensionContext.cwd, which is unavailable + // while the extension factory runs. Absolute and named PI_TASKS overrides can + // still be opened immediately; all other stores start in memory. + let storeTarget = resolveStoreTarget(); + let store = new TaskStore(storeTarget.path); const tracker = new ProcessTracker(); const widget = new TaskWidget(store, cfg); @@ -333,26 +343,40 @@ export default function (pi: ExtensionAPI) { widget.update(); }); - // ── Session-scoped store upgrade ── - // For session scope, the store starts in-memory (no session ID at init time). - // Upgrade to file-backed on first context arrival (turn_start, before_agent_start, - // or tool_execution_start — whichever fires first). - let storeUpgraded = false; + // ── Context-scoped store initialization ── + // Project paths cannot be resolved until an ExtensionContext is available. + // Initialize on the first context-bearing event and reinitialize when a host + // switches this extension instance to a session in another workspace. + let configuredCwd: string | undefined; let persistedTasksShown = false; let agentsReattached = false; - function upgradeStoreIfNeeded(ctx: ExtensionContext) { - if (storeUpgraded) return; - if (taskScope === "session" && !piTasks) { - // `pi --no-session` mints a session ID but never a session file. Keying off the - // ID alone would write tasks-.json for a session that can never be resumed - // and is orphaned the moment pi exits: if pi is not persisting the conversation, - // don't persist the task list either. - const sessionId = ctx.sessionManager.getSessionFile() ? ctx.sessionManager.getSessionId() : undefined; - const path = sessionId ? resolveStorePath(sessionId) : undefined; - store = new TaskStore(path); + function initializeStoreForContext(ctx: ExtensionContext, reloadConfig = false) { + // Keep the config object identity stable because the widget and auto-clear + // manager retain references to it, but replace every value so overrides + // from a previous workspace cannot leak into the next one. + if (reloadConfig || configuredCwd !== ctx.cwd) { + for (const key of Object.keys(cfg) as (keyof typeof cfg)[]) delete cfg[key]; + Object.assign(cfg, loadTasksConfig(ctx.cwd)); + taskScope = cfg.taskScope ?? "session"; + } + + // `pi --no-session` mints a session ID but never a session file. Keying off the + // ID alone would write tasks-.json for a session that can never be resumed + // and is orphaned the moment pi exits: if pi is not persisting the conversation, + // don't persist the task list either. + const sessionId = taskScope === "session" && !piTasks && ctx.sessionManager.getSessionFile() + ? ctx.sessionManager.getSessionId() + : undefined; + const nextTarget = resolveStoreTarget(ctx.cwd, sessionId); + if (nextTarget.key !== storeTarget.key) { + store = new TaskStore(nextTarget.path); widget.setStore(store); + storeTarget = nextTarget; + // The new store owns a different task list, so the agent map has to be + // rebuilt from it rather than kept from the previous one. + agentsReattached = false; } - storeUpgraded = true; + configuredCwd = ctx.cwd; } /** Re-link persisted in-progress tasks to the subagents still running for them. @@ -406,7 +430,7 @@ export default function (pi: ExtensionAPI) { onTurnStart(cadence); latestCtx = ctx; widget.setUICtx(ctx.ui as UICtx); - upgradeStoreIfNeeded(ctx); + initializeStoreForContext(ctx); if (autoClear.onTurnStart(cadence.currentTurn)) widget.update(); }); @@ -501,7 +525,6 @@ export default function (pi: ExtensionAPI) { // copy. Snapshot before the store re-points to the new (empty) session file. const forkSeed = reason === "fork" ? store.snapshot() : undefined; if (isSwitch) { - storeUpgraded = false; persistedTasksShown = false; agentsReattached = false; // Task IDs restart at 1 in every session, so a mapping held over from the @@ -516,7 +539,7 @@ export default function (pi: ExtensionAPI) { } } - upgradeStoreIfNeeded(ctx); // re-points a session store once storeUpgraded is cleared + initializeStoreForContext(ctx, true); if (forkSeed?.tasks.length) store.seed(forkSeed); // carry the parent's tasks into the fork reattachAgents(); // subagents outlive a reload; relink them before events arrive // resume/reload/fork keep tasks; startup/new auto-clear an all-completed list. @@ -533,7 +556,7 @@ export default function (pi: ExtensionAPI) { pi.on("before_agent_start", async (_event, ctx) => { latestCtx = ctx; widget.setUICtx(ctx.ui as UICtx); - upgradeStoreIfNeeded(ctx); + initializeStoreForContext(ctx); reattachAgents(); showPersistedTasks(); if (pendingWarning) { @@ -546,7 +569,7 @@ export default function (pi: ExtensionAPI) { pi.on("tool_execution_start", async (_event, ctx) => { latestCtx = ctx; widget.setUICtx(ctx.ui as UICtx); - upgradeStoreIfNeeded(ctx); + initializeStoreForContext(ctx); widget.update(); }); @@ -1140,6 +1163,9 @@ Set up task dependencies: pi.registerCommand("tasks", { description: "Manage tasks — view, create, clear completed", handler: async (_args: string, ctx: ExtensionCommandContext) => { + latestCtx = ctx; + widget.setUICtx(ctx.ui as UICtx); + initializeStoreForContext(ctx); const ui = ctx.ui; const mainMenu = async (): Promise => { @@ -1245,7 +1271,7 @@ Set up task dependencies: }; const settingsMenu = (): Promise => - openSettingsMenu(ui, cfg, mainMenu, AUTO_CLEAR_DELAY); + openSettingsMenu(ui, cfg, mainMenu, AUTO_CLEAR_DELAY, ctx.cwd); const createTask = async (): Promise => { const subject = await ui.input("Task subject"); diff --git a/src/tasks-config.ts b/src/tasks-config.ts index dc3e207..7486c7a 100644 --- a/src/tasks-config.ts +++ b/src/tasks-config.ts @@ -24,15 +24,19 @@ function readTasksConfig(configPath: string): TasksConfig { } } -export function loadTasksConfig(cwd = process.cwd(), agentDir = getAgentDir()): TasksConfig { - const globalConfig = readTasksConfig(join(agentDir, "tasks-config.json")); +export function loadGlobalTasksConfig(agentDir = getAgentDir()): TasksConfig { + return readTasksConfig(join(agentDir, "tasks-config.json")); +} + +export function loadTasksConfig(cwd: string, agentDir = getAgentDir()): TasksConfig { + const globalConfig = loadGlobalTasksConfig(agentDir); const projectConfig = readTasksConfig(join(cwd, ".pi", "tasks-config.json")); return { ...globalConfig, ...projectConfig }; } -export function saveTasksConfig(config: TasksConfig, cwd = process.cwd(), agentDir = getAgentDir()): void { +export function saveTasksConfig(config: TasksConfig, cwd: string, agentDir = getAgentDir()): void { const configPath = join(cwd, ".pi", "tasks-config.json"); - const globalConfig = readTasksConfig(join(agentDir, "tasks-config.json")); + const globalConfig = loadGlobalTasksConfig(agentDir); const projectOverrides = Object.fromEntries(Object.entries(config).filter(([key, value]) => globalConfig[key as keyof TasksConfig] !== value)); mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, JSON.stringify(projectOverrides, null, 2)); diff --git a/src/ui/settings-menu.ts b/src/ui/settings-menu.ts index 6ea14ac..26595b5 100644 --- a/src/ui/settings-menu.ts +++ b/src/ui/settings-menu.ts @@ -26,6 +26,7 @@ export async function openSettingsMenu( cfg: TasksConfig, onBack: () => Promise, clearDelayTurns: number, + cwd: string, ): Promise { await ui.custom((_tui, theme, _kb, done) => { const items: SettingItem[] = [ @@ -105,31 +106,31 @@ export async function openSettingsMenu( /* onChange */ (id, newValue) => { if (id === "autoCascade") { cfg.autoCascade = newValue === "on"; - saveTasksConfig(cfg); + saveTasksConfig(cfg, cwd); } if (id === "taskScope") { cfg.taskScope = newValue as "memory" | "session" | "project"; - saveTasksConfig(cfg); + saveTasksConfig(cfg, cwd); } if (id === "autoClearCompleted") { cfg.autoClearCompleted = newValue as TasksConfig["autoClearCompleted"]; - saveTasksConfig(cfg); + saveTasksConfig(cfg, cwd); } if (id === "showAll") { cfg.showAll = newValue === "on"; - saveTasksConfig(cfg); + saveTasksConfig(cfg, cwd); } if (id === "maxVisible") { cfg.maxVisible = Number(newValue); - saveTasksConfig(cfg); + saveTasksConfig(cfg, cwd); } if (id === "sortOrder") { cfg.sortOrder = newValue as TasksConfig["sortOrder"]; - saveTasksConfig(cfg); + saveTasksConfig(cfg, cwd); } if (id === "hiddenAt") { cfg.hiddenAt = newValue as "top" | "bottom"; - saveTasksConfig(cfg); + saveTasksConfig(cfg, cwd); } }, /* onCancel */ () => done(undefined), diff --git a/test/agent-reattach.test.ts b/test/agent-reattach.test.ts index c0b5115..3971c75 100644 --- a/test/agent-reattach.test.ts +++ b/test/agent-reattach.test.ts @@ -21,6 +21,7 @@ import { flush, installSubagentsMock, mockPi, mockSessionCtx } from "./helpers/m // these tests exercise. const config = vi.hoisted(() => ({ current: {} as Record })); vi.mock("../src/tasks-config.js", () => ({ + loadGlobalTasksConfig: () => ({ ...config.current }), loadTasksConfig: () => ({ ...config.current }), saveTasksConfig: () => {}, })); diff --git a/test/auto-cascade.test.ts b/test/auto-cascade.test.ts index 190bd58..acb6ff8 100644 --- a/test/auto-cascade.test.ts +++ b/test/auto-cascade.test.ts @@ -16,6 +16,7 @@ import { flush, installSubagentsMock, mockCtx, mockPi } from "./helpers/mock-pi. // developer's global /tasks-config.json leak into the results. const config = vi.hoisted(() => ({ current: {} as Record })); vi.mock("../src/tasks-config.js", () => ({ + loadGlobalTasksConfig: () => ({ ...config.current }), loadTasksConfig: () => ({ ...config.current }), saveTasksConfig: () => {}, })); diff --git a/test/helpers/mock-pi.ts b/test/helpers/mock-pi.ts index c592ad9..4682a88 100644 --- a/test/helpers/mock-pi.ts +++ b/test/helpers/mock-pi.ts @@ -88,8 +88,10 @@ export function mockPi() { export type MockPi = ReturnType; /** Minimal mock ExtensionContext. */ -export function mockCtx() { +export function mockCtx(cwd = process.cwd()) { return { + // Task paths resolve against the session workspace, not the host process cwd. + cwd, model: { id: "test-model", name: "Test" }, modelRegistry: {}, ui: { @@ -107,10 +109,10 @@ export function mockCtx() { * persisting (`pi --no-session`, `SessionManager.inMemory()`) reports a session ID * but no file. Pass `{ persisted: false }` for the latter. */ -export function mockSessionCtx(sessionId: string, opts?: { persisted?: boolean }) { +export function mockSessionCtx(sessionId: string, opts?: { persisted?: boolean; cwd?: string }) { const sessionFile = opts?.persisted === false ? undefined : `/sessions/${sessionId}.jsonl`; return { - ...mockCtx(), + ...mockCtx(opts?.cwd), sessionManager: { getSessionId: vi.fn(() => sessionId), getSessionFile: vi.fn(() => sessionFile), diff --git a/test/stale-task-reminder.test.ts b/test/stale-task-reminder.test.ts index 90aa7a7..1f73101 100644 --- a/test/stale-task-reminder.test.ts +++ b/test/stale-task-reminder.test.ts @@ -6,6 +6,7 @@ afterEach(() => { delete process.env.PI_TASKS; }); function mockCtx() { return { + cwd: process.cwd(), model: { id: "test-model", name: "Test" }, modelRegistry: {}, ui: { @@ -50,6 +51,7 @@ function mockPi() { async executeTool(name: string, params: any, ctx = mockCtx()) { const tool = tools.get(name); if (!tool) throw new Error(`Tool ${name} not registered`); + await this.fireLifecycle("tool_execution_start", { toolName: name }, ctx); const result = await tool.execute("call-1", params, undefined, undefined, ctx); await this.fireLifecycle("tool_result", { toolName: name }); return result; @@ -57,7 +59,10 @@ function mockPi() { async runCommand(name: string, ui: any) { const cmd = commands.get(name); if (!cmd) throw new Error(`Command ${name} not registered`); - return cmd.handler("", { ui }); + // ExtensionCommandContext extends ExtensionContext: it carries cwd and the same + // ui surface, plus the prompt helpers the command drives. + const ctx = mockCtx(); + return cmd.handler("", { ...ctx, ui: { ...ctx.ui, ...ui } }); }, async fireLifecycle(event: string, ...args: any[]) { let lastResult: any; diff --git a/test/store-scope.test.ts b/test/store-scope.test.ts index 9abf9d2..d55d9a9 100644 --- a/test/store-scope.test.ts +++ b/test/store-scope.test.ts @@ -2,8 +2,8 @@ * Where tasks are stored: the taskScope config, the PI_TASKS override, and what * session_start does with an already-persisted list. * - * process.cwd() is stubbed for every test — .pi/ in the real working directory holds - * the developer's own task list and must never be written to by the suite. + * Every context carries a temp workspace: task paths resolve against ctx.cwd, and + * .pi/ in the real working directory holds the developer's own task list. */ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; @@ -16,6 +16,7 @@ import { mockPi, mockSessionCtx } from "./helpers/mock-pi.js"; const config = vi.hoisted(() => ({ current: {} as Record })); vi.mock("../src/tasks-config.js", () => ({ + loadGlobalTasksConfig: () => ({ ...config.current }), loadTasksConfig: () => ({ ...config.current }), saveTasksConfig: () => {}, })); @@ -24,7 +25,6 @@ let cwd: string; beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), "pi-tasks-scope-")); - vi.spyOn(process, "cwd").mockReturnValue(cwd); config.current = {}; delete process.env.PI_TASKS; }); @@ -35,6 +35,9 @@ afterEach(() => { rmSync(cwd, { recursive: true, force: true }); }); +/** Every context carries the workspace, since that is what task paths resolve against. */ +const ctxFor = (sessionId = "s1", opts?: { persisted?: boolean }) => + mockSessionCtx(sessionId, { ...opts, cwd }); const projectFile = () => join(cwd, ".pi", "tasks", "tasks.json"); const sessionFile = (id: string) => join(cwd, ".pi", "tasks", `tasks-${id}.json`); @@ -44,6 +47,7 @@ describe("taskScope: project", () => { it("persists to a single shared file", async () => { const mock = mockPi(); initExtension(mock.pi as any); + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxFor("s1")); await mock.executeTool("TaskCreate", { subject: "Shared", description: "d" }); expect(existsSync(projectFile())).toBe(true); @@ -53,10 +57,10 @@ describe("taskScope: project", () => { it("stays on the same file when the session changes", async () => { const mock = mockPi(); initExtension(mock.pi as any); - await mock.fireLifecycle("session_start", { reason: "startup" }, mockSessionCtx("s1")); + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxFor("s1")); await mock.executeTool("TaskCreate", { subject: "Before", description: "d" }); - await mock.fireLifecycle("session_start", { reason: "new" }, mockSessionCtx("s2")); + await mock.fireLifecycle("session_start", { reason: "new" }, ctxFor("s2")); await mock.executeTool("TaskCreate", { subject: "After", description: "d" }); expect(existsSync(sessionFile("s1"))).toBe(false); @@ -71,7 +75,7 @@ describe("taskScope: memory", () => { it("never touches the filesystem", async () => { const mock = mockPi(); initExtension(mock.pi as any); - await mock.fireLifecycle("session_start", { reason: "startup" }, mockSessionCtx("s1")); + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxFor("s1")); await mock.executeTool("TaskCreate", { subject: "Ephemeral", description: "d" }); expect(existsSync(join(cwd, ".pi"))).toBe(false); @@ -81,10 +85,10 @@ describe("taskScope: memory", () => { it("clears tasks on /new, since there is no file to switch away from", async () => { const mock = mockPi(); initExtension(mock.pi as any); - await mock.fireLifecycle("session_start", { reason: "startup" }, mockSessionCtx("s1")); + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxFor("s1")); await mock.executeTool("TaskCreate", { subject: "Ephemeral", description: "d" }); - await mock.fireLifecycle("session_start", { reason: "new" }, mockSessionCtx("s2")); + await mock.fireLifecycle("session_start", { reason: "new" }, ctxFor("s2")); expect((await mock.executeTool("TaskList", {})).content[0].text).toBe("No tasks found"); }); @@ -92,10 +96,10 @@ describe("taskScope: memory", () => { it("keeps tasks across a reload", async () => { const mock = mockPi(); initExtension(mock.pi as any); - await mock.fireLifecycle("session_start", { reason: "startup" }, mockSessionCtx("s1")); + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxFor("s1")); await mock.executeTool("TaskCreate", { subject: "Ephemeral", description: "d" }); - await mock.fireLifecycle("session_start", { reason: "reload" }, mockSessionCtx("s1")); + await mock.fireLifecycle("session_start", { reason: "reload" }, ctxFor("s1")); expect((await mock.executeTool("TaskList", {})).content[0].text).toContain("Ephemeral"); }); @@ -107,7 +111,7 @@ describe("taskScope: session, without a persisted session", () => { it("keeps tasks in memory and leaves nothing on disk", async () => { const mock = mockPi(); initExtension(mock.pi as any); - await mock.fireLifecycle("session_start", { reason: "startup" }, mockSessionCtx("s1", { persisted: false })); + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxFor("s1", { persisted: false })); await mock.executeTool("TaskCreate", { subject: "Ephemeral", description: "d" }); expect(existsSync(join(cwd, ".pi"))).toBe(false); @@ -117,7 +121,7 @@ describe("taskScope: session, without a persisted session", () => { it("still writes a session file when the session is persisted", async () => { const mock = mockPi(); initExtension(mock.pi as any); - await mock.fireLifecycle("session_start", { reason: "startup" }, mockSessionCtx("s1")); + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxFor("s1")); await mock.executeTool("TaskCreate", { subject: "Durable", description: "d" }); expect(existsSync(sessionFile("s1"))).toBe(true); @@ -126,7 +130,7 @@ describe("taskScope: session, without a persisted session", () => { it("does not fall back to a file when a later lifecycle event fires", async () => { const mock = mockPi(); initExtension(mock.pi as any); - const ctx = mockSessionCtx("s1", { persisted: false }); + const ctx = ctxFor("s1", { persisted: false }); await mock.fireLifecycle("session_start", { reason: "startup" }, ctx); await mock.fireLifecycle("before_agent_start", {}, ctx); await mock.fireLifecycle("turn_start", {}, ctx); @@ -137,12 +141,13 @@ describe("taskScope: session, without a persisted session", () => { }); describe("PI_TASKS override", () => { - it("resolves a relative path against the working directory", async () => { + it("resolves a relative path against the session workspace", async () => { process.env.PI_TASKS = "./custom/list.json"; config.current = { taskScope: "memory" }; // overridden by the env var const mock = mockPi(); initExtension(mock.pi as any); + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxFor("s1")); await mock.executeTool("TaskCreate", { subject: "Relative", description: "d" }); const file = join(cwd, "custom", "list.json"); @@ -178,7 +183,7 @@ describe("session_start with a persisted list", () => { const mock = mockPi(); initExtension(mock.pi as any); - const ctx = mockSessionCtx("s1"); + const ctx = ctxFor("s1"); await mock.fireLifecycle("session_start", { reason: "startup" }, ctx); expect(existsSync(sessionFile("s1"))).toBe(false); @@ -190,7 +195,7 @@ describe("session_start with a persisted list", () => { const mock = mockPi(); initExtension(mock.pi as any); - const ctx = mockSessionCtx("s1"); + const ctx = ctxFor("s1"); await mock.fireLifecycle("session_start", { reason: "resume" }, ctx); expect(existsSync(sessionFile("s1"))).toBe(true); @@ -204,7 +209,7 @@ describe("session_start with a persisted list", () => { const mock = mockPi(); initExtension(mock.pi as any); - const ctx = mockSessionCtx("s1"); + const ctx = ctxFor("s1"); await mock.fireLifecycle("session_start", { reason: "startup" }, ctx); expect(existsSync(sessionFile("s1"))).toBe(true); diff --git a/test/subagent-integration.test.ts b/test/subagent-integration.test.ts index defbb34..efc68c1 100644 --- a/test/subagent-integration.test.ts +++ b/test/subagent-integration.test.ts @@ -3,7 +3,7 @@ * auto-cascade, and widget agent ID display. */ -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -17,6 +17,7 @@ import { installSubagentsMock, type MockEventBus, mockCtx, mockPi, mockSessionCt // developer's global /tasks-config.json leak into the results. const config = vi.hoisted(() => ({ current: {} as Record })); vi.mock("../src/tasks-config.js", () => ({ + loadGlobalTasksConfig: () => ({ ...config.current }), loadTasksConfig: () => ({ ...config.current }), saveTasksConfig: () => {}, })); @@ -30,27 +31,29 @@ beforeEach(() => { afterEach(() => { delete process.env.PI_TASKS; }); describe("Session task rehydration", () => { - // Session-scoped stores resolve against the working directory. Point that at a - // temp directory: .pi/ in the real one holds the developer's own task list. + // Task paths resolve against the session workspace (ctx.cwd), so every test gets + // its own: .pi/ in the real working directory holds the developer's own task list. let cwd: string; beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), "pi-tasks-session-")); - vi.spyOn(process, "cwd").mockReturnValue(cwd); }); afterEach(() => { vi.restoreAllMocks(); rmSync(cwd, { recursive: true, force: true }); }); + const sessionCtx = (sessionId: string) => mockSessionCtx(sessionId, { cwd }); + const sessionFile = (sessionId: string) => join(cwd, ".pi", "tasks", `tasks-${sessionId}.json`); + it("renders default session-scoped tasks immediately after reload", async () => { const sessionId = `reload-${process.pid}-${Date.now()}`; - const taskFile = join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`); + const taskFile = sessionFile(sessionId); try { new TaskStore(taskFile).create("Review the rerun", "Inspect final results"); delete process.env.PI_TASKS; const mock = mockPi(); initExtension(mock.pi as any); - const ctx = mockSessionCtx(sessionId); + const ctx = sessionCtx(sessionId); await mock.fireLifecycle("session_start", { reason: "reload" }, ctx); @@ -71,7 +74,7 @@ describe("Session task rehydration", () => { process.env.PI_TASKS = taskFile; const mock = mockPi(); initExtension(mock.pi as any); - const ctx = mockCtx(); + const ctx = mockCtx(cwd); await mock.fireLifecycle("session_start", { reason: "reload" }, ctx); @@ -85,13 +88,13 @@ describe("Session task rehydration", () => { it("renders persisted tasks after /resume", async () => { const sessionId = `resume-${process.pid}-${Date.now()}`; - const taskFile = join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`); + const taskFile = sessionFile(sessionId); try { new TaskStore(taskFile).create("Resume this", "Pick up where we left off"); delete process.env.PI_TASKS; const mock = mockPi(); initExtension(mock.pi as any); - const ctx = mockSessionCtx(sessionId); + const ctx = sessionCtx(sessionId); await mock.fireLifecycle("session_start", { reason: "resume" }, ctx); @@ -106,8 +109,8 @@ describe("Session task rehydration", () => { it("switches the session-scoped store to the new session on /new", async () => { const sessionA = `switch-a-${process.pid}-${Date.now()}`; const sessionB = `switch-b-${process.pid}-${Date.now()}`; - const fileA = join(process.cwd(), ".pi", "tasks", `tasks-${sessionA}.json`); - const fileB = join(process.cwd(), ".pi", "tasks", `tasks-${sessionB}.json`); + const fileA = sessionFile(sessionA); + const fileB = sessionFile(sessionB); try { new TaskStore(fileA).create("Task in A", "desc"); new TaskStore(fileB).create("Task in B", "desc"); @@ -115,15 +118,13 @@ describe("Session task rehydration", () => { const mock = mockPi(); initExtension(mock.pi as any); - const ctxA = mockSessionCtx(sessionA); + const ctxA = sessionCtx(sessionA); await mock.fireLifecycle("session_start", { reason: "startup" }, ctxA); expect(ctxA.sessionManager.getSessionId).toHaveBeenCalledOnce(); - // /new must reset storeUpgraded and re-point at the new session file — - // previously handled by the (never-emitted) session_switch event. Without - // that reset, storeUpgraded stays true and getSessionId is never called - // again, leaving the store stuck on session A. - const ctxB = mockSessionCtx(sessionB); + // /new must re-point at the new session file. This was previously handled + // by the never-emitted session_switch event, leaving the store on session A. + const ctxB = sessionCtx(sessionB); await mock.fireLifecycle("session_start", { reason: "new" }, ctxB); expect(ctxB.sessionManager.getSessionId).toHaveBeenCalledOnce(); } finally { @@ -135,21 +136,21 @@ describe("Session task rehydration", () => { it("seeds a forked session with an independent copy of the parent's tasks", async () => { const parent = `fork-parent-${process.pid}-${Date.now()}`; const child = `fork-child-${process.pid}-${Date.now()}`; - const parentFile = join(process.cwd(), ".pi", "tasks", `tasks-${parent}.json`); - const childFile = join(process.cwd(), ".pi", "tasks", `tasks-${child}.json`); + const parentFile = sessionFile(parent); + const childFile = sessionFile(child); try { new TaskStore(parentFile).create("Inherited task", "carry me into the fork"); delete process.env.PI_TASKS; const mock = mockPi(); initExtension(mock.pi as any); - const ctxP = mockSessionCtx(parent); + const ctxP = sessionCtx(parent); await mock.fireLifecycle("session_start", { reason: "startup" }, ctxP); // /fork re-points to a brand-new (empty) session file. Without seeding, the // fork would silently lose the parent's tasks; with it, the fork gets an // independent copy that does not write back to the parent. - const ctxC = mockSessionCtx(child); + const ctxC = sessionCtx(child); await mock.fireLifecycle("session_start", { reason: "fork" }, ctxC); const forked = new TaskStore(childFile).list(); @@ -165,6 +166,112 @@ describe("Session task rehydration", () => { }); }); +describe("Workspace-scoped store resolution", () => { + // Paths come from ExtensionContext.cwd, not process.cwd(). The two match in the + // terminal host, but a long-lived host serving sessions from another directory + // would otherwise write every workspace's tasks into its own. + const workspaces: string[] = []; + const workspace = (label: string) => { + const dir = mkdtempSync(join(tmpdir(), `pi-tasks-${label}-`)); + workspaces.push(dir); + return dir; + }; + + afterEach(() => { + for (const dir of workspaces.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + it("stores session tasks under ctx.cwd instead of the host process cwd", async () => { + const cwd = workspace("workspace"); + const sessionId = `ctx-cwd-${process.pid}-${Date.now()}`; + const taskFile = join(cwd, ".pi", "tasks", `tasks-${sessionId}.json`); + const hostTaskFile = join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`); + delete process.env.PI_TASKS; + const mock = mockPi(); + initExtension(mock.pi as any); + const ctx = mockSessionCtx(sessionId, { cwd }); + + await mock.fireLifecycle("session_start", { reason: "startup" }, ctx); + await mock.executeTool("TaskCreate", { + subject: "Workspace task", + description: "Must use the session workspace", + }, ctx); + + expect(new TaskStore(taskFile).list().map(t => t.subject)).toEqual(["Workspace task"]); + expect(existsSync(hostTaskFile)).toBe(false); + }); + + it("loads project scope from ctx.cwd and stores the shared task list there", async () => { + const cwd = workspace("project-scope"); + config.current = { taskScope: "project" }; + delete process.env.PI_TASKS; + const mock = mockPi(); + initExtension(mock.pi as any); + const ctx = mockCtx(cwd); + + await mock.fireLifecycle("session_start", { reason: "startup" }, ctx); + await mock.executeTool("TaskCreate", { + subject: "Shared workspace task", + description: "Must use the project-scoped store", + }, ctx); + + const taskFile = join(cwd, ".pi", "tasks", "tasks.json"); + expect(new TaskStore(taskFile).list().map(t => t.subject)).toEqual(["Shared workspace task"]); + }); + + it("resolves relative PI_TASKS paths from ctx.cwd", async () => { + const cwd = workspace("relative"); + process.env.PI_TASKS = "./state/tasks.json"; + const mock = mockPi(); + initExtension(mock.pi as any); + const ctx = mockCtx(cwd); + + await mock.fireLifecycle("session_start", { reason: "startup" }, ctx); + await mock.executeTool("TaskCreate", { + subject: "Relative override task", + description: "Must resolve relative to the session workspace", + }, ctx); + + const taskFile = join(cwd, "state", "tasks.json"); + expect(new TaskStore(taskFile).list().map(t => t.subject)).toEqual(["Relative override task"]); + }); + + it("switches session stores when the session ID changes in the same workspace", async () => { + const cwd = workspace("session-switch"); + const sessionA = `same-cwd-a-${process.pid}-${Date.now()}`; + const sessionB = `same-cwd-b-${process.pid}-${Date.now()}`; + delete process.env.PI_TASKS; + const mock = mockPi(); + initExtension(mock.pi as any); + const ctxA = mockSessionCtx(sessionA, { cwd }); + const ctxB = mockSessionCtx(sessionB, { cwd }); + + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxA); + await mock.executeTool("TaskCreate", { subject: "Task A", description: "Session A" }, ctxA); + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxB); + await mock.executeTool("TaskCreate", { subject: "Task B", description: "Session B" }, ctxB); + + const file = (id: string) => join(cwd, ".pi", "tasks", `tasks-${id}.json`); + expect(new TaskStore(file(sessionA)).list().map(t => t.subject)).toEqual(["Task A"]); + expect(new TaskStore(file(sessionB)).list().map(t => t.subject)).toEqual(["Task B"]); + }); + + it("keeps an in-memory store when the context cwd changes", async () => { + const ctxA = mockCtx(workspace("memory-a")); + const ctxB = mockCtx(workspace("memory-b")); + process.env.PI_TASKS = "off"; + const mock = mockPi(); + initExtension(mock.pi as any); + + await mock.fireLifecycle("session_start", { reason: "startup" }, ctxA); + await mock.executeTool("TaskCreate", { subject: "Memory task", description: "Keep me" }, ctxA); + await mock.fireLifecycle("turn_start", {}, ctxB); + + const result = await mock.executeTool("TaskList", {}, ctxB); + expect(result.content[0].text).toContain("Memory task"); + }); +}); + // ---- Tests ---- describe("TaskExecute", () => { diff --git a/test/tasks-command.test.ts b/test/tasks-command.test.ts index 5bc6e59..39624cf 100644 --- a/test/tasks-command.test.ts +++ b/test/tasks-command.test.ts @@ -14,6 +14,7 @@ import { mockPi } from "./helpers/mock-pi.js"; const config = vi.hoisted(() => ({ current: {} as Record })); vi.mock("../src/tasks-config.js", () => ({ + loadGlobalTasksConfig: () => ({ ...config.current }), loadTasksConfig: () => ({ ...config.current }), saveTasksConfig: () => {}, })); diff --git a/test/tasks-config.test.ts b/test/tasks-config.test.ts index dc6c85c..2fd47e6 100644 --- a/test/tasks-config.test.ts +++ b/test/tasks-config.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { loadTasksConfig, saveTasksConfig } from "../src/tasks-config.js"; +import { loadGlobalTasksConfig, loadTasksConfig, saveTasksConfig } from "../src/tasks-config.js"; function writeJson(path: string, value: unknown): void { mkdirSync(dirname(path), { recursive: true }); @@ -37,6 +37,7 @@ describe("tasks config", () => { it("loads global defaults from the agent directory", () => { writeJson(globalConfigPath, { autoCascade: true, maxVisible: 20 }); + expect(loadGlobalTasksConfig(agentDir)).toEqual({ autoCascade: true, maxVisible: 20 }); expect(loadTasksConfig(cwd, agentDir)).toEqual({ autoCascade: true, maxVisible: 20 }); });