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:
nikuscs
2026-07-20 22:04:03 +01:00
committed by GitHub
parent 045de763f7
commit 187561e433
10 changed files with 259 additions and 36 deletions

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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;

View File

@@ -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 },
]);
});
});

View File

@@ -947,6 +947,7 @@ async function collectOpenCodeImportableSessionsFromSdk(
archived: true,
roots: true,
limit: sessionListLimit,
...(options?.cwd ? { directory: options.cwd } : {}),
});
if (response.error) {

View File

@@ -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");

View File

@@ -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;