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
24 changes: 17 additions & 7 deletions packages/coding-agent/src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<encoded-cwd>/).
*/
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);
}
Expand Down Expand Up @@ -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/<encoded-cwd>/).
* @param onProgress Optional callback for progress updates (loaded, total)
*/
static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {
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;
Expand Down
4 changes: 1 addition & 3 deletions packages/coding-agent/src/utils/pi-user-agent.ts
Original file line number Diff line number Diff line change
@@ -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})`;
}
78 changes: 76 additions & 2 deletions packages/coding-agent/test/session-manager/file-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
32 changes: 32 additions & 0 deletions packages/coding-agent/test/step-session-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/test/step-user-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*[^()]+\)$/);
});
});
2 changes: 1 addition & 1 deletion packages/providers/src/utils/pi-user-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)";
}
Loading