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-21 05:04:03 +08:00
committed by GitHub
parent 045de763f7
commit 187561e433
10 changed files with 259 additions and 36 deletions
@@ -242,7 +242,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
}); });
expect(listImportableSessions).toHaveBeenCalledWith({ expect(listImportableSessions).toHaveBeenCalledWith({
limit: 2, limit: 4,
providerFilter: new Set(["codex"]), providerFilter: new Set(["codex"]),
cwd, 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 () => { test("listImportableProviderSessions includes a provider session after its Paseo agent is archived", async () => {
const cwd = "/tmp/project"; const cwd = "/tmp/project";
const archivedSession = makeImportableSession({ const archivedSession = makeImportableSession({
@@ -121,10 +121,15 @@ export async function listImportableProviderSessions(
const limit = request.limit ?? 20; const limit = request.limit ?? 20;
const sinceTimestamp = parseRecentProviderSessionsSince(request.since); const sinceTimestamp = parseRecentProviderSessionsSince(request.since);
const providerFilter = request.providers ? new Set(request.providers) : undefined; 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({ const sessions = await agentManager.listImportableSessions({
limit, limit: limit + importedSessions.count,
providerFilter, providerFilter,
cwd: request.cwd, cwd: request.cwd,
}); });
@@ -328,29 +333,40 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
return timestamp; return timestamp;
} }
async function collectImportedProviderSessionHandles( async function collectImportedProviderSessions(
agentManager: Pick<AgentManager, "listAgents">, agentManager: Pick<AgentManager, "listAgents">,
agentStorage: Pick<AgentStorage, "list">, agentStorage: Pick<AgentStorage, "list">,
): Promise<Set<string>> { providerFilter: Set<string> | undefined,
): Promise<{ handles: Set<string>; count: number }> {
const handles = new Set<string>(); const handles = new Set<string>();
const sessions = new Set<string>();
const records = await agentStorage.list(); const records = await agentStorage.list();
const storedRecordsById = new Map(records.map((record) => [record.id, record])); 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()) { for (const agent of agentManager.listAgents()) {
if (storedRecordsById.get(agent.id)?.archivedAt) { if (storedRecordsById.get(agent.id)?.archivedAt) {
continue; continue;
} }
collectProviderSessionHandleKeys(handles, agent.provider, agent.persistence); collect(agent.provider, agent.persistence);
} }
for (const record of records) { for (const record of records) {
if (record.archivedAt) { if (record.archivedAt) {
continue; 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 { function toProviderSessionHandleKey(provider: string, providerHandleId: string): string {
@@ -13,6 +13,7 @@ import {
normalizeClaudeAskUserQuestionUpdatedInput, normalizeClaudeAskUserQuestionUpdatedInput,
toClaudeSdkMcpConfig, toClaudeSdkMcpConfig,
} from "./agent.js"; } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { streamSession } from "../test-utils/session-stream-adapter.js"; import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js";
@@ -1081,6 +1082,75 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
}); });
describe("ClaudeAgentClient.listImportableSessions", () => { 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 () => { test("shows Claude slash command prompts without transcript tags", async () => {
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-")); const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-"));
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
@@ -1235,7 +1305,7 @@ describe("ClaudeAgentSession context window usage", () => {
} }
void (async () => { void (async () => {
for await (const _prompt of prompt) { for await (const _ of prompt) {
const turnMessages = turns[turnIndex] ?? []; const turnMessages = turns[turnIndex] ?? [];
turnIndex += 1; turnIndex += 1;
for (const message of turnMessages) { for (const message of turnMessages) {
@@ -1535,12 +1535,16 @@ export class ClaudeAgentClient implements AgentClient {
options?: ListImportableSessionsOptions, options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> { ): Promise<ImportableProviderSession[]> {
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const projectsRoot = path.join(configDir, "projects"); const sessionsRoot = options?.cwd
if (!(await pathExists(projectsRoot))) { ? claudeProjectDirSync(options.cwd, { configDir })
: path.join(configDir, "projects");
if (!(await pathExists(sessionsRoot))) {
return []; return [];
} }
const limit = options?.limit ?? 20; 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( const parsed = await Promise.all(
candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)), candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)),
); );
@@ -5470,29 +5474,33 @@ async function pathExists(target: string): Promise<boolean> {
async function collectRecentClaudeSessions( async function collectRecentClaudeSessions(
root: string, root: string,
limit: number, limit: number,
options?: { rootIsProjectDir?: boolean },
): Promise<ClaudeSessionCandidate[]> { ): Promise<ClaudeSessionCandidate[]> {
let projectDirs: string[]; let rootEntries: string[];
try { try {
projectDirs = await fsPromises.readdir(root); rootEntries = await fsPromises.readdir(root);
} catch { } catch {
return []; return [];
} }
const projectFileLists = await Promise.all( const fileEntries = options?.rootIsProjectDir
projectDirs.map(async (dirName) => { ? rootEntries.filter((file) => file.endsWith(".jsonl")).map((file) => path.join(root, file))
const projectPath = path.join(root, dirName); : (
try { await Promise.all(
const stats = await fsPromises.stat(projectPath); rootEntries.map(async (dirName) => {
if (!stats.isDirectory()) return { projectPath, files: [] as string[] }; const projectPath = path.join(root, dirName);
const files = await fsPromises.readdir(projectPath); try {
return { projectPath, files }; const stats = await fsPromises.stat(projectPath);
} catch { if (!stats.isDirectory()) return [] as string[];
return { projectPath, files: [] as string[] }; const files = await fsPromises.readdir(projectPath);
} return files
}), .filter((file) => file.endsWith(".jsonl"))
); .map((file) => path.join(projectPath, file));
const fileEntries = projectFileLists.flatMap(({ projectPath, files }) => } catch {
files.filter((f) => f.endsWith(".jsonl")).map((f) => path.join(projectPath, f)), return [] as string[];
); }
}),
)
).flat();
const statResults = await Promise.all( const statResults = await Promise.all(
fileEntries.map(async (fullPath) => { fileEntries.map(async (fullPath) => {
try { try {
@@ -13,6 +13,42 @@ async function writeSession(root: string, relativePath: string, lines: unknown[]
} }
describe("OMP session descriptor", () => { 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 () => { test("reads title-first sessions and OMP combined model identifiers", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-title-first-")); const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-title-first-"));
const cwd = path.join(root, "repo"); const cwd = path.join(root, "repo");
@@ -80,9 +80,10 @@ export async function listOmpImportableSessions(
const limit = options.limit ?? 20; const limit = options.limit ?? 20;
const ranked = await rankSessionFilesByMtime(files); const ranked = await rankSessionFilesByMtime(files);
const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN); const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN);
const candidates = matchesCwd ? ranked : ranked.slice(0, candidateLimit);
const sessions: ImportableProviderSession[] = []; const sessions: ImportableProviderSession[] = [];
for (const entry of ranked.slice(0, candidateLimit)) { for (const entry of candidates) {
const session = await readOmpImportableSession(entry.file); const session = await readOmpImportableSession(entry.file);
if (!session) continue; if (!session) continue;
if (matchesCwd && !matchesCwd(session.cwd)) 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 runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
const cwd = "/workspace/repo"; 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(sessions[0]?.lastActivityAt.toISOString()).toBe("1970-01-01T00:00:03.000Z");
expect(runtime.clientCreations).toEqual([{ baseUrl: runtime.server.url, directory: cwd }]); expect(runtime.clientCreations).toEqual([{ baseUrl: runtime.server.url, directory: cwd }]);
expect(openCodeClient.calls.experimentalSessionList).toEqual([ expect(openCodeClient.calls.experimentalSessionList).toEqual([
{ archived: true, roots: true, limit: 200 }, { archived: true, roots: true, limit: 200, directory: cwd },
]); ]);
expect(openCodeClient.calls.sessionMessages).toEqual([]); expect(openCodeClient.calls.sessionMessages).toEqual([]);
}); });
@@ -2411,7 +2411,7 @@ describe("OpenCode persisted sessions", () => {
title: "Windows session", title: "Windows session",
}); });
expect(openCodeClient.calls.experimentalSessionList).toEqual([ 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, archived: true,
roots: true, roots: true,
limit: sessionListLimit, limit: sessionListLimit,
...(options?.cwd ? { directory: options.cwd } : {}),
}); });
if (response.error) { 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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
@@ -13,6 +13,52 @@ async function writeSession(root: string, lines: unknown[]): Promise<string> {
return filePath; 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 () => { 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 root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-model-"));
const cwd = path.join(root, "repo"); const cwd = path.join(root, "repo");
@@ -78,9 +78,10 @@ export async function listPiImportableSessions(
const limit = options.limit ?? 20; const limit = options.limit ?? 20;
const ranked = await rankSessionFilesByMtime(files); const ranked = await rankSessionFilesByMtime(files);
const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN); const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN);
const candidates = matchesCwd ? ranked : ranked.slice(0, candidateLimit);
const sessions: ImportableProviderSession[] = []; const sessions: ImportableProviderSession[] = [];
for (const entry of ranked.slice(0, candidateLimit)) { for (const entry of candidates) {
const session = await readPiImportableSession(entry.file); const session = await readPiImportableSession(entry.file);
if (!session) continue; if (!session) continue;
if (matchesCwd && !matchesCwd(session.cwd)) continue; if (matchesCwd && !matchesCwd(session.cwd)) continue;