From 7e94d36a44479326a6b2ee4227fc41f10ea978aa Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 26 Mar 2026 00:28:30 +0100 Subject: [PATCH] fix(coding-agent): add follow-up docs, changelog, and precedence tests closes #2429 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/extensions.md | 3 +- .../test/session-dir-precedence.test.ts | 158 ++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/session-dir-precedence.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2055b2c8..f07b3e99 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `sessionDir` setting support in global and project `settings.json` so session storage can be configured without passing `--session-dir` on every invocation ([#2598](https://github.com/badlogic/pi-mono/pull/2598) by [@smcllns](https://github.com/smcllns)) + ### Fixed - Added missing `ajv` direct dependency; previously relied on transitive install via `@mariozechner/pi-ai` which broke standalone installs ([#2252](https://github.com/badlogic/pi-mono/issues/2252)) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 8a847434..5fde9ffb 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -293,10 +293,11 @@ Fired by the `pi` CLI during startup session resolution, before the initial sess This event is: - CLI-only. It is not emitted in SDK mode. - Startup-only. It is not emitted for later interactive `/new` or `/resume` actions. -- Bypassed when `--session-dir` is provided. +- Lower priority than `--session-dir` and `sessionDir` in `settings.json`. - Special-cased to receive no `ctx` argument. If multiple extensions return `sessionDir`, the last one wins. +Combined precedence is: `--session-dir` CLI flag, then `sessionDir` in settings, then extension `session_directory` hooks. ```typescript pi.on("session_directory", async (event) => { diff --git a/packages/coding-agent/test/session-dir-precedence.test.ts b/packages/coding-agent/test/session-dir-precedence.test.ts new file mode 100644 index 00000000..b017e0da --- /dev/null +++ b/packages/coding-agent/test/session-dir-precedence.test.ts @@ -0,0 +1,158 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ENV_AGENT_DIR } from "../src/config.js"; + +const mocks = vi.hoisted(() => ({ + state: { + hookSessionDir: undefined as string | undefined, + capturedSessionDir: undefined as string | undefined, + }, + createAgentSession: vi.fn(async (options: { sessionManager?: { getSessionDir(): string } }) => { + mocks.state.capturedSessionDir = options.sessionManager?.getSessionDir(); + return { + session: { + model: { id: "test-model", provider: "test", reasoning: false }, + thinkingLevel: "off", + setThinkingLevel: vi.fn(), + }, + modelFallbackMessage: undefined, + }; + }), + runPrintMode: vi.fn(async () => 0), + selectSession: vi.fn(), +})); + +vi.mock("../src/core/sdk.js", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + createAgentSession: mocks.createAgentSession, + }; +}); + +vi.mock("../src/modes/index.js", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + runPrintMode: mocks.runPrintMode, + }; +}); + +vi.mock("../src/cli/session-picker.js", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + selectSession: mocks.selectSession, + }; +}); + +vi.mock("../src/core/resource-loader.js", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + DefaultResourceLoader: class { + async reload(): Promise {} + + getExtensions() { + const handlers = new Map(); + if (mocks.state.hookSessionDir) { + handlers.set("session_directory", [async () => ({ sessionDir: mocks.state.hookSessionDir })]); + } + return { + extensions: [{ path: "/mock-extension.ts", handlers, flags: new Map() }], + errors: [], + runtime: { + pendingProviderRegistrations: [], + flagValues: new Map(), + }, + }; + } + }, + }; +}); + +describe("sessionDir precedence", () => { + let tempDir: string; + let agentDir: string; + let projectDir: string; + let originalCwd: string; + let originalAgentDir: string | undefined; + let originalExitCode: typeof process.exitCode; + let originalIsTTY: boolean | undefined; + + beforeEach(() => { + vi.resetModules(); + mocks.state.hookSessionDir = "./hook-sessions"; + mocks.state.capturedSessionDir = undefined; + mocks.createAgentSession.mockClear(); + mocks.runPrintMode.mockClear(); + mocks.selectSession.mockReset(); + + tempDir = join(tmpdir(), `pi-session-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`); + agentDir = join(tempDir, "agent"); + projectDir = join(tempDir, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + + originalCwd = process.cwd(); + originalAgentDir = process.env[ENV_AGENT_DIR]; + originalExitCode = process.exitCode; + originalIsTTY = process.stdin.isTTY; + process.exitCode = undefined; + process.env[ENV_AGENT_DIR] = agentDir; + process.chdir(projectDir); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + }); + + afterEach(() => { + process.chdir(originalCwd); + process.exitCode = originalExitCode; + Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + if (originalAgentDir === undefined) { + delete process.env[ENV_AGENT_DIR]; + } else { + process.env[ENV_AGENT_DIR] = originalAgentDir; + } + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("prefers settings sessionDir over the session_directory hook for new sessions", async () => { + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ sessionDir: "./settings-sessions" })); + + const { main } = await import("../src/main.js"); + await main(["--print", "test prompt"]); + + expect(mocks.state.capturedSessionDir).toBe("./settings-sessions"); + expect(mocks.runPrintMode).toHaveBeenCalledOnce(); + }); + + it("prefers CLI --session-dir over settings and the session_directory hook", async () => { + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ sessionDir: "./settings-sessions" })); + + const { main } = await import("../src/main.js"); + await main(["--print", "--session-dir", "./cli-sessions", "test prompt"]); + + expect(mocks.state.capturedSessionDir).toBe("./cli-sessions"); + expect(mocks.runPrintMode).toHaveBeenCalledOnce(); + }); + + it("uses settings sessionDir ahead of the session_directory hook for --resume", async () => { + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ sessionDir: "./settings-sessions" })); + + const { SessionManager } = await import("../src/core/session-manager.js"); + const listSpy = vi.spyOn(SessionManager, "list"); + mocks.selectSession.mockImplementation(async (listCurrent: (onProgress: () => void) => Promise) => { + await listCurrent(() => {}); + return join(projectDir, "picked-session.jsonl"); + }); + + const { main } = await import("../src/main.js"); + await main(["--print", "--resume"]); + + expect(listSpy).toHaveBeenCalledWith(expect.any(String), "./settings-sessions", expect.any(Function)); + expect(mocks.state.capturedSessionDir).toBe("./settings-sessions"); + expect(mocks.runPrintMode).toHaveBeenCalledOnce(); + }); +});