mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix: workspace-scoped session imports across Claude Code, OpenCode, Pi, and OMP (#2265)
* fix(claude): scope import discovery to workspace * fix(providers): preserve workspace session discovery
This commit is contained in:
@@ -242,7 +242,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
});
|
||||
|
||||
expect(listImportableSessions).toHaveBeenCalledWith({
|
||||
limit: 2,
|
||||
limit: 4,
|
||||
providerFilter: new Set(["codex"]),
|
||||
cwd,
|
||||
});
|
||||
@@ -273,6 +273,50 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
});
|
||||
});
|
||||
|
||||
test("listImportableProviderSessions looks past already-imported rows to fill the requested limit", async () => {
|
||||
const cwd = "/tmp/project";
|
||||
const imported = makeImportableSession({
|
||||
provider: "claude",
|
||||
sessionId: "already-imported",
|
||||
cwd,
|
||||
lastActivityAt: "2026-04-30T12:02:00.000Z",
|
||||
});
|
||||
const available = makeImportableSession({
|
||||
provider: "claude",
|
||||
sessionId: "available",
|
||||
cwd,
|
||||
lastActivityAt: "2026-04-30T12:01:00.000Z",
|
||||
});
|
||||
const listImportableSessions = vi.fn(async (options?: { limit?: number }) =>
|
||||
[imported, available].slice(0, options?.limit),
|
||||
);
|
||||
|
||||
const result = await listImportableProviderSessions({
|
||||
request: makeRequest({ cwd, providers: ["claude"], limit: 1 }),
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
listImportableSessions,
|
||||
},
|
||||
agentStorage: {
|
||||
list: async () => [
|
||||
{
|
||||
provider: "claude",
|
||||
persistence: { provider: "claude", sessionId: "already-imported" },
|
||||
} as StoredAgentRecord,
|
||||
],
|
||||
},
|
||||
providerSnapshotManager: { getProviderLabel: () => "Claude Code" },
|
||||
});
|
||||
|
||||
expect(listImportableSessions).toHaveBeenCalledWith({
|
||||
limit: 2,
|
||||
providerFilter: new Set(["claude"]),
|
||||
cwd,
|
||||
});
|
||||
expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["available"]);
|
||||
expect(result.filteredAlreadyImportedCount).toBe(1);
|
||||
});
|
||||
|
||||
test("listImportableProviderSessions includes a provider session after its Paseo agent is archived", async () => {
|
||||
const cwd = "/tmp/project";
|
||||
const archivedSession = makeImportableSession({
|
||||
|
||||
@@ -121,10 +121,15 @@ export async function listImportableProviderSessions(
|
||||
const limit = request.limit ?? 20;
|
||||
const sinceTimestamp = parseRecentProviderSessionsSince(request.since);
|
||||
const providerFilter = request.providers ? new Set(request.providers) : undefined;
|
||||
const importedHandles = await collectImportedProviderSessionHandles(agentManager, agentStorage);
|
||||
const importedSessions = await collectImportedProviderSessions(
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerFilter,
|
||||
);
|
||||
const importedHandles = importedSessions.handles;
|
||||
|
||||
const sessions = await agentManager.listImportableSessions({
|
||||
limit,
|
||||
limit: limit + importedSessions.count,
|
||||
providerFilter,
|
||||
cwd: request.cwd,
|
||||
});
|
||||
@@ -328,29 +333,40 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
async function collectImportedProviderSessionHandles(
|
||||
async function collectImportedProviderSessions(
|
||||
agentManager: Pick<AgentManager, "listAgents">,
|
||||
agentStorage: Pick<AgentStorage, "list">,
|
||||
): Promise<Set<string>> {
|
||||
providerFilter: Set<string> | undefined,
|
||||
): Promise<{ handles: Set<string>; count: number }> {
|
||||
const handles = new Set<string>();
|
||||
const sessions = new Set<string>();
|
||||
const records = await agentStorage.list();
|
||||
const storedRecordsById = new Map(records.map((record) => [record.id, record]));
|
||||
|
||||
const collect = (
|
||||
provider: AgentProvider | StoredAgentRecord["provider"] | string,
|
||||
persistence: AgentPersistenceHandle | null | undefined,
|
||||
) => {
|
||||
if (!persistence || (providerFilter && !providerFilter.has(provider))) return;
|
||||
sessions.add(toProviderSessionHandleKey(provider, persistence.sessionId));
|
||||
collectProviderSessionHandleKeys(handles, provider, persistence);
|
||||
};
|
||||
|
||||
for (const agent of agentManager.listAgents()) {
|
||||
if (storedRecordsById.get(agent.id)?.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
collectProviderSessionHandleKeys(handles, agent.provider, agent.persistence);
|
||||
collect(agent.provider, agent.persistence);
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
if (record.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
collectProviderSessionHandleKeys(handles, record.provider, record.persistence);
|
||||
collect(record.provider, record.persistence);
|
||||
}
|
||||
|
||||
return handles;
|
||||
return { handles, count: sessions.size };
|
||||
}
|
||||
|
||||
function toProviderSessionHandleKey(provider: string, providerHandleId: string): string {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
normalizeClaudeAskUserQuestionUpdatedInput,
|
||||
toClaudeSdkMcpConfig,
|
||||
} from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
|
||||
@@ -1081,6 +1082,75 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||
});
|
||||
|
||||
describe("ClaudeAgentClient.listImportableSessions", () => {
|
||||
test("scopes candidates to the requested cwd before applying the limit", async () => {
|
||||
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-"));
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir;
|
||||
|
||||
try {
|
||||
const requestedCwd = path.join(tmpConfigDir, "requested-project");
|
||||
const busyCwd = path.join(tmpConfigDir, "busy-project");
|
||||
await fs.mkdir(requestedCwd, { recursive: true });
|
||||
await fs.mkdir(busyCwd, { recursive: true });
|
||||
const requestedProjectDir = claudeProjectDirSync(requestedCwd, { configDir: tmpConfigDir });
|
||||
const busyProjectDir = claudeProjectDirSync(busyCwd, { configDir: tmpConfigDir });
|
||||
await fs.mkdir(requestedProjectDir, { recursive: true });
|
||||
await fs.mkdir(busyProjectDir, { recursive: true });
|
||||
|
||||
const writeSession = async (
|
||||
projectDir: string,
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
day: number,
|
||||
) => {
|
||||
const file = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
await fs.writeFile(
|
||||
file,
|
||||
`${JSON.stringify({
|
||||
isSidechain: false,
|
||||
type: "user",
|
||||
message: { role: "user", content: `Prompt for ${sessionId}` },
|
||||
cwd,
|
||||
sessionId,
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const timestamp = new Date(`2026-06-${String(day).padStart(2, "0")}T12:00:00.000Z`);
|
||||
await fs.utimes(file, timestamp, timestamp);
|
||||
};
|
||||
|
||||
await writeSession(requestedProjectDir, "requested-session", requestedCwd, 1);
|
||||
await writeSession(busyProjectDir, "newer-session-1", busyCwd, 2);
|
||||
await writeSession(busyProjectDir, "newer-session-2", busyCwd, 3);
|
||||
await writeSession(busyProjectDir, "newer-session-3", busyCwd, 4);
|
||||
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
|
||||
await expect(client.listImportableSessions({ limit: 1, cwd: requestedCwd })).resolves.toEqual(
|
||||
[
|
||||
{
|
||||
providerHandleId: "requested-session",
|
||||
cwd: requestedCwd,
|
||||
title: "Prompt for requested-session",
|
||||
firstPromptPreview: "Prompt for requested-session",
|
||||
lastPromptPreview: "Prompt for requested-session",
|
||||
lastActivityAt: new Date("2026-06-01T12:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||
}
|
||||
await fs.rm(tmpConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("shows Claude slash command prompts without transcript tags", async () => {
|
||||
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-"));
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
@@ -1235,7 +1305,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
for await (const _prompt of prompt) {
|
||||
for await (const _ of prompt) {
|
||||
const turnMessages = turns[turnIndex] ?? [];
|
||||
turnIndex += 1;
|
||||
for (const message of turnMessages) {
|
||||
|
||||
@@ -1535,12 +1535,16 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
const projectsRoot = path.join(configDir, "projects");
|
||||
if (!(await pathExists(projectsRoot))) {
|
||||
const sessionsRoot = options?.cwd
|
||||
? claudeProjectDirSync(options.cwd, { configDir })
|
||||
: path.join(configDir, "projects");
|
||||
if (!(await pathExists(sessionsRoot))) {
|
||||
return [];
|
||||
}
|
||||
const limit = options?.limit ?? 20;
|
||||
const candidates = await collectRecentClaudeSessions(projectsRoot, limit * 3);
|
||||
const candidates = await collectRecentClaudeSessions(sessionsRoot, limit * 3, {
|
||||
rootIsProjectDir: Boolean(options?.cwd),
|
||||
});
|
||||
const parsed = await Promise.all(
|
||||
candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)),
|
||||
);
|
||||
@@ -5470,29 +5474,33 @@ async function pathExists(target: string): Promise<boolean> {
|
||||
async function collectRecentClaudeSessions(
|
||||
root: string,
|
||||
limit: number,
|
||||
options?: { rootIsProjectDir?: boolean },
|
||||
): Promise<ClaudeSessionCandidate[]> {
|
||||
let projectDirs: string[];
|
||||
let rootEntries: string[];
|
||||
try {
|
||||
projectDirs = await fsPromises.readdir(root);
|
||||
rootEntries = await fsPromises.readdir(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const projectFileLists = await Promise.all(
|
||||
projectDirs.map(async (dirName) => {
|
||||
const projectPath = path.join(root, dirName);
|
||||
try {
|
||||
const stats = await fsPromises.stat(projectPath);
|
||||
if (!stats.isDirectory()) return { projectPath, files: [] as string[] };
|
||||
const files = await fsPromises.readdir(projectPath);
|
||||
return { projectPath, files };
|
||||
} catch {
|
||||
return { projectPath, files: [] as string[] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
const fileEntries = projectFileLists.flatMap(({ projectPath, files }) =>
|
||||
files.filter((f) => f.endsWith(".jsonl")).map((f) => path.join(projectPath, f)),
|
||||
);
|
||||
const fileEntries = options?.rootIsProjectDir
|
||||
? rootEntries.filter((file) => file.endsWith(".jsonl")).map((file) => path.join(root, file))
|
||||
: (
|
||||
await Promise.all(
|
||||
rootEntries.map(async (dirName) => {
|
||||
const projectPath = path.join(root, dirName);
|
||||
try {
|
||||
const stats = await fsPromises.stat(projectPath);
|
||||
if (!stats.isDirectory()) return [] as string[];
|
||||
const files = await fsPromises.readdir(projectPath);
|
||||
return files
|
||||
.filter((file) => file.endsWith(".jsonl"))
|
||||
.map((file) => path.join(projectPath, file));
|
||||
} catch {
|
||||
return [] as string[];
|
||||
}
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
const statResults = await Promise.all(
|
||||
fileEntries.map(async (fullPath) => {
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,42 @@ async function writeSession(root: string, relativePath: string, lines: unknown[]
|
||||
}
|
||||
|
||||
describe("OMP session descriptor", () => {
|
||||
test("cwd filtering continues past the global candidate overscan", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-cwd-limit-"));
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
const requestedCwd = path.join(root, "requested");
|
||||
const otherCwd = path.join(root, "other");
|
||||
const requestedFile = await writeSession(root, "requested/requested.jsonl", [
|
||||
{
|
||||
type: "session",
|
||||
id: "requested-session",
|
||||
timestamp: "2026-06-01T00:00:00.000Z",
|
||||
cwd: requestedCwd,
|
||||
},
|
||||
]);
|
||||
await utimes(requestedFile, new Date("2026-06-01"), new Date("2026-06-01"));
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 400 }, async (_, index) => {
|
||||
const file = await writeSession(root, `other/${index}.jsonl`, [
|
||||
{
|
||||
type: "session",
|
||||
id: `other-${index}`,
|
||||
timestamp: "2026-06-02T00:00:00.000Z",
|
||||
cwd: otherCwd,
|
||||
},
|
||||
]);
|
||||
await utimes(file, new Date("2026-06-02"), new Date("2026-06-02"));
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
listOmpImportableSessions({ sessionDir: sessionsDir, cwd: requestedCwd, limit: 1 }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ providerHandleId: requestedFile, cwd: requestedCwd }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("reads title-first sessions and OMP combined model identifiers", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-title-first-"));
|
||||
const cwd = path.join(root, "repo");
|
||||
|
||||
@@ -80,9 +80,10 @@ export async function listOmpImportableSessions(
|
||||
const limit = options.limit ?? 20;
|
||||
const ranked = await rankSessionFilesByMtime(files);
|
||||
const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN);
|
||||
const candidates = matchesCwd ? ranked : ranked.slice(0, candidateLimit);
|
||||
const sessions: ImportableProviderSession[] = [];
|
||||
|
||||
for (const entry of ranked.slice(0, candidateLimit)) {
|
||||
for (const entry of candidates) {
|
||||
const session = await readOmpImportableSession(entry.file);
|
||||
if (!session) continue;
|
||||
if (matchesCwd && !matchesCwd(session.cwd)) continue;
|
||||
|
||||
@@ -2175,7 +2175,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("listImportableSessions returns rows without hydrating session messages", async () => {
|
||||
test("listImportableSessions scopes upstream rows without hydrating session messages", async () => {
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
const cwd = "/workspace/repo";
|
||||
@@ -2290,7 +2290,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
expect(sessions[0]?.lastActivityAt.toISOString()).toBe("1970-01-01T00:00:03.000Z");
|
||||
expect(runtime.clientCreations).toEqual([{ baseUrl: runtime.server.url, directory: cwd }]);
|
||||
expect(openCodeClient.calls.experimentalSessionList).toEqual([
|
||||
{ archived: true, roots: true, limit: 200 },
|
||||
{ archived: true, roots: true, limit: 200, directory: cwd },
|
||||
]);
|
||||
expect(openCodeClient.calls.sessionMessages).toEqual([]);
|
||||
});
|
||||
@@ -2411,7 +2411,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
title: "Windows session",
|
||||
});
|
||||
expect(openCodeClient.calls.experimentalSessionList).toEqual([
|
||||
{ archived: true, roots: true, limit: 200 },
|
||||
{ archived: true, roots: true, limit: 200, directory: requestedCwd },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -947,6 +947,7 @@ async function collectOpenCodeImportableSessionsFromSdk(
|
||||
archived: true,
|
||||
roots: true,
|
||||
limit: sessionListLimit,
|
||||
...(options?.cwd ? { directory: options.cwd } : {}),
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -13,6 +13,52 @@ async function writeSession(root: string, lines: unknown[]): Promise<string> {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
test("Pi cwd filtering continues past the global candidate overscan", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-cwd-limit-"));
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
const requestedCwd = path.join(root, "requested");
|
||||
const otherCwd = path.join(root, "other");
|
||||
const requestedFile = path.join(sessionsDir, "requested", "requested.jsonl");
|
||||
await mkdir(path.dirname(requestedFile), { recursive: true });
|
||||
await writeFile(
|
||||
requestedFile,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "requested-session",
|
||||
timestamp: "2026-06-01T00:00:00.000Z",
|
||||
cwd: requestedCwd,
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await utimes(requestedFile, new Date("2026-06-01"), new Date("2026-06-01"));
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 400 }, async (_, index) => {
|
||||
const file = path.join(sessionsDir, "other", `${index}.jsonl`);
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(
|
||||
file,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: `other-${index}`,
|
||||
timestamp: "2026-06-02T00:00:00.000Z",
|
||||
cwd: otherCwd,
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await utimes(file, new Date("2026-06-02"), new Date("2026-06-02"));
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
listPiImportableSessions({ sessionDir: sessionsDir, cwd: requestedCwd, limit: 1 }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ providerHandleId: requestedFile, cwd: requestedCwd }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Pi import config preserves the latest recorded model and thinking level", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-model-"));
|
||||
const cwd = path.join(root, "repo");
|
||||
|
||||
@@ -78,9 +78,10 @@ export async function listPiImportableSessions(
|
||||
const limit = options.limit ?? 20;
|
||||
const ranked = await rankSessionFilesByMtime(files);
|
||||
const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN);
|
||||
const candidates = matchesCwd ? ranked : ranked.slice(0, candidateLimit);
|
||||
const sessions: ImportableProviderSession[] = [];
|
||||
|
||||
for (const entry of ranked.slice(0, candidateLimit)) {
|
||||
for (const entry of candidates) {
|
||||
const session = await readPiImportableSession(entry.file);
|
||||
if (!session) continue;
|
||||
if (matchesCwd && !matchesCwd(session.cwd)) continue;
|
||||
|
||||
Reference in New Issue
Block a user