From 9c148204334fd8ee99e96488cb02d31bc3359bdd Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:36:39 +0800 Subject: [PATCH 1/2] fix: scope session listing and continue to the header cwd The default session directory name is a lossy encoding of the cwd (path separators collapse to '-'), so /tmp/case/project-a and /tmp/case/project/a map to the same directory. SessionManager.list() and continueRecent() only filtered by the session header cwd when an explicit non-default sessionDir was supplied, so under a collision the current project's listing showed the other project's sessions and --continue appended to whichever file was newest, ignoring its header cwd. Both now always match the header cwd; listAll() still scans every project directory. Regression tests cover the colliding-paths scenario at the native and Step facade levels (issue #156). --- .../coding-agent/src/core/session-manager.ts | 24 ++++-- .../session-manager/file-operations.test.ts | 78 ++++++++++++++++++- .../test/step-session-wrapper.test.ts | 32 ++++++++ 3 files changed, 125 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index aab0c0e9..47a2042f 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1552,14 +1552,19 @@ export class SessionManager { } /** - * Continue the most recent session, or create new if none. + * Continue the most recent session for this project, or create new if none. + * + * The default session directory name is a lossy encoding of the cwd (path + * separators collapse to '-'), so distinct projects such as `/x/project-a` + * and `/x/project/a` share one directory. Discovery therefore always matches + * each candidate's session header cwd instead of trusting the directory name. + * * @param cwd Working directory * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). */ static continueRecent(cwd: string, sessionDir?: string): SessionManager { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); - const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined); + const mostRecent = findMostRecentSession(dir, cwd); if (mostRecent) { return new SessionManager(cwd, dir, mostRecent, true); } @@ -1632,17 +1637,22 @@ export class SessionManager { } /** - * List all sessions for a directory. + * List the sessions that belong to one project. + * + * The default session directory name is a lossy encoding of the cwd (path + * separators collapse to '-'), so distinct projects such as `/x/project-a` + * and `/x/project/a` share one directory. Every candidate is therefore + * matched against its session header cwd; use listAll() to scan every project. + * * @param cwd Working directory (used to compute default session directory) * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). * @param onProgress Optional callback for progress updates (loaded, total) */ static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); - const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); const resolvedCwd = resolvePath(cwd); - const sessions = (await listSessionsFromDir(dir, onProgress)).filter( - (session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd), + const sessions = (await listSessionsFromDir(dir, onProgress)).filter((session) => + sessionCwdMatches(session.cwd, resolvedCwd), ); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index 1785078e..5cb4c56e 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -2,8 +2,13 @@ import { constants as bufferConstants } from "buffer"; import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { findMostRecentSession, loadEntriesFromFile, SessionManager } from "../../src/core/session-manager.ts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + findMostRecentSession, + getDefaultSessionDir, + loadEntriesFromFile, + SessionManager, +} from "../../src/core/session-manager.ts"; const HEADER_SCAN_LIMIT_BYTES = 1024 * 1024; @@ -327,6 +332,75 @@ describe("SessionManager custom flat session directory", () => { }); }); +describe("SessionManager default session directory with colliding project paths", () => { + let tempDir: string; + let agentDir: string; + let projectA: string; + let projectASlash: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `session-collision-${Date.now()}`); + agentDir = join(tempDir, "agent"); + // Both paths encode to the same default session directory name. + projectA = join(tempDir, "case", "project-a"); + projectASlash = join(tempDir, "case", "project", "a"); + mkdirSync(projectA, { recursive: true }); + mkdirSync(projectASlash, { recursive: true }); + vi.stubEnv("STEP_CODING_AGENT_DIR", agentDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + function createDefaultSession(cwd: string, id: string): string { + const session = SessionManager.create(cwd, undefined, { id }); + const file = session.getSessionFile(); + if (!file) { + throw new Error("Expected a persisted session file"); + } + writeFileSync(file, `${JSON.stringify(session.getHeader())}\n`); + return file; + } + + it("maps colliding project paths onto one default session directory", () => { + expect(getDefaultSessionDir(projectA, agentDir)).toBe(getDefaultSessionDir(projectASlash, agentDir)); + }); + + it("lists only the current project's sessions from the shared default directory", async () => { + const own = createDefaultSession(projectA, "own"); + await new Promise((r) => setTimeout(r, 10)); + const foreign = createDefaultSession(projectASlash, "foreign"); + + const listed = await SessionManager.list(projectA); + expect(listed.map((session) => session.path)).toEqual([own]); + + const foreignListed = await SessionManager.list(projectASlash); + expect(foreignListed.map((session) => session.path)).toEqual([foreign]); + }); + + it("continues the current project's newest session, not the foreign one", async () => { + createDefaultSession(projectA, "own"); + await new Promise((r) => setTimeout(r, 10)); + // The colliding project owns the newest file in the shared directory. + createDefaultSession(projectASlash, "foreign"); + + const continued = SessionManager.continueRecent(projectA); + expect(continued.getSessionId()).toBe("own"); + expect(continued.getCwd()).toBe(projectA); + }); + + it("creates a new session when the shared directory only holds foreign sessions", async () => { + createDefaultSession(projectASlash, "foreign"); + + const continued = SessionManager.continueRecent(projectA); + expect(continued.getSessionId()).not.toBe("foreign"); + expect(continued.getCwd()).toBe(projectA); + expect(continued.getSessionFile()).toContain(getDefaultSessionDir(projectA, agentDir)); + }); +}); + describe("SessionManager.setSessionFile with corrupted files", () => { let tempDir: string; diff --git a/packages/coding-agent/test/step-session-wrapper.test.ts b/packages/coding-agent/test/step-session-wrapper.test.ts index d6a1c7f3..77bcfdfa 100644 --- a/packages/coding-agent/test/step-session-wrapper.test.ts +++ b/packages/coding-agent/test/step-session-wrapper.test.ts @@ -151,6 +151,38 @@ describe("Step Pi storage wrappers", () => { expect(fork.getSessionDir()).not.toContain(`${join(root, ".pi")}/`); }); + test("keeps colliding project paths out of each other's session listings", async () => { + const root = await mkdtemp(join(tmpdir(), "step-session-collision-")); + roots.push(root); + const projectA = join(root, "case", "project-a"); + const projectASlash = join(root, "case", "project", "a"); + const agentDir = join(root, ".stepcode", "agent"); + await Promise.all([mkdir(projectA, { recursive: true }), mkdir(projectASlash, { recursive: true })]); + vi.stubEnv("STEP_CODING_AGENT_DIR", agentDir); + + const own = StepSessionManager.create(projectA, { agentDir, newSession: { id: "own" } }); + const ownPath = await persistHeader(own); + await new Promise((r) => setTimeout(r, 10)); + const foreign = StepSessionManager.create(projectASlash, { agentDir, newSession: { id: "foreign" } }); + await persistHeader(foreign); + + // Both projects encode to the same default session directory, which the + // CLI resolves explicitly when no session dir is configured. + const cliSessionDir = own.getSessionDir(); + expect(cliSessionDir).toBe(foreign.getSessionDir()); + + await expect(listStepSessions(projectA, { agentDir, sessionDir: cliSessionDir })).resolves.toMatchObject([ + { id: "own" }, + ]); + await expect(listStepSessions(projectASlash, { agentDir, sessionDir: cliSessionDir })).resolves.toMatchObject([ + { id: "foreign" }, + ]); + + const continued = continueStepSession(projectA, { agentDir, sessionDir: cliSessionDir }); + expect(continued.getSessionId()).toBe("own"); + expect(continued.getSessionFile()).toBe(ownPath); + }); + test("accepts Pi's positional static-session signatures", async () => { const root = await mkdtemp(join(tmpdir(), "step-session-positional-")); roots.push(root); From 943e624b5cc06497fd95dabcff74253367ef5c00 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:57:49 +0800 Subject: [PATCH 2/2] fix: brand the user agent as stepcode Provider requests and the public getPiUserAgent() helper identified the client as `step`, while the update checker already sends `stepcode`. Use `stepcode` in both user-agent builders so the client is attributed consistently across requests. --- packages/coding-agent/src/utils/pi-user-agent.ts | 4 +--- packages/coding-agent/test/step-user-agent.test.ts | 4 ++-- packages/providers/src/utils/pi-user-agent.ts | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/utils/pi-user-agent.ts b/packages/coding-agent/src/utils/pi-user-agent.ts index b4d676b6..f45d8baf 100644 --- a/packages/coding-agent/src/utils/pi-user-agent.ts +++ b/packages/coding-agent/src/utils/pi-user-agent.ts @@ -1,6 +1,4 @@ -import { APP_NAME } from "../config.ts"; - export function getPiUserAgent(version: string): string { const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`; - return `${APP_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`; + return `stepcode/${version} (${process.platform}; ${runtime}; ${process.arch})`; } diff --git a/packages/coding-agent/test/step-user-agent.test.ts b/packages/coding-agent/test/step-user-agent.test.ts index f8d40b01..bcd0336e 100644 --- a/packages/coding-agent/test/step-user-agent.test.ts +++ b/packages/coding-agent/test/step-user-agent.test.ts @@ -6,7 +6,7 @@ describe("getPiUserAgent", () => { const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`; const userAgent = getPiUserAgent("1.2.3"); - expect(userAgent).toBe(`step/1.2.3 (${process.platform}; ${runtime}; ${process.arch})`); - expect(userAgent).toMatch(/^step\/[^\s()]+ \([^;()]+;\s*[^;()]+;\s*[^()]+\)$/); + expect(userAgent).toBe(`stepcode/1.2.3 (${process.platform}; ${runtime}; ${process.arch})`); + expect(userAgent).toMatch(/^stepcode\/[^\s()]+ \([^;()]+;\s*[^;()]+;\s*[^()]+\)$/); }); }); diff --git a/packages/providers/src/utils/pi-user-agent.ts b/packages/providers/src/utils/pi-user-agent.ts index c4fb41e7..7daf0d7f 100644 --- a/packages/providers/src/utils/pi-user-agent.ts +++ b/packages/providers/src/utils/pi-user-agent.ts @@ -15,5 +15,5 @@ function loadNodeOs(): typeof NodeOs | null { const nodeOs = loadNodeOs(); export function getPiUserAgent(): string { - return nodeOs ? `step (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "step (browser)"; + return nodeOs ? `stepcode (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "stepcode (browser)"; }