From 187561e4335d2c83c099120d5b98fcd7e6cdd763 Mon Sep 17 00:00:00 2001 From: nikuscs Date: Mon, 20 Jul 2026 22:04:03 +0100 Subject: [PATCH] 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 --- .../src/server/agent/import-sessions.test.ts | 46 +++++++++++- .../src/server/agent/import-sessions.ts | 30 ++++++-- .../agent/providers/claude/agent.test.ts | 72 ++++++++++++++++++- .../server/agent/providers/claude/agent.ts | 50 +++++++------ .../providers/omp/session-descriptor.test.ts | 36 ++++++++++ .../agent/providers/omp/session-descriptor.ts | 3 +- .../agent/providers/opencode-agent.test.ts | 6 +- .../server/agent/providers/opencode-agent.ts | 1 + .../providers/pi/session-descriptor.test.ts | 48 ++++++++++++- .../agent/providers/pi/session-descriptor.ts | 3 +- 10 files changed, 259 insertions(+), 36 deletions(-) diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index 266ed29ab..70f169318 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -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({ diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 7ec047e92..b1281c27b 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -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, agentStorage: Pick, -): Promise> { + providerFilter: Set | undefined, +): Promise<{ handles: Set; count: number }> { const handles = new Set(); + const sessions = new Set(); 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 { diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index fc0a5d3ba..50e505011 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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) { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 675e9561b..9b0429fff 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1535,12 +1535,16 @@ export class ClaudeAgentClient implements AgentClient { options?: ListImportableSessionsOptions, ): Promise { 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 { async function collectRecentClaudeSessions( root: string, limit: number, + options?: { rootIsProjectDir?: boolean }, ): Promise { - 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 { diff --git a/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts b/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts index f4f5bda2f..9dc4ba876 100644 --- a/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts +++ b/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/providers/omp/session-descriptor.ts b/packages/server/src/server/agent/providers/omp/session-descriptor.ts index 7282549a3..16e4ee7fd 100644 --- a/packages/server/src/server/agent/providers/omp/session-descriptor.ts +++ b/packages/server/src/server/agent/providers/omp/session-descriptor.ts @@ -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; diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 26f3b89cb..e7ac1e858 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -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 }, ]); }); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 954896b6a..3ed1dff42 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -947,6 +947,7 @@ async function collectOpenCodeImportableSessionsFromSdk( archived: true, roots: true, limit: sessionListLimit, + ...(options?.cwd ? { directory: options.cwd } : {}), }); if (response.error) { diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts index 575f59392..280b55873 100644 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts @@ -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 { 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"); diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.ts index df4aa2560..187174fb8 100644 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.ts +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.ts @@ -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;