From 51d1d007ce96490454267b9d63d1af64bd9ee854 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 10 Jun 2026 11:25:16 +0700 Subject: [PATCH] Import provider sessions as ready agents Provider listing now returns picker rows only. Importing a selected provider session hydrates its timeline before the daemon publishes the Paseo agent. --- docs/providers.md | 10 +- .../components/import-session-sheet.test.tsx | 47 +++-- .../src/components/import-session-sheet.tsx | 175 ++++++++++++----- .../src/server/agent/agent-manager.test.ts | 166 ++++++++++------- .../server/src/server/agent/agent-manager.ts | 176 +++++++++++++----- .../server/agent/agent-projections.test.ts | 39 ++-- .../src/server/agent/agent-projections.ts | 43 +---- .../src/server/agent/agent-sdk-types.ts | 47 ++++- .../src/server/agent/import-sessions.test.ts | 142 ++++++-------- .../src/server/agent/import-sessions.ts | 120 ++++-------- .../src/server/agent/provider-registry.ts | 54 +++--- .../server/agent/provider-session-import.ts | 77 ++++++++ .../src/server/agent/providers/acp-agent.ts | 40 ++-- .../server/agent/providers/claude/agent.ts | 68 ++++--- ...codex-app-server-agent.spawn-error.test.ts | 4 +- .../providers/codex-app-server-agent.test.ts | 20 +- .../agent/providers/codex-app-server-agent.ts | 49 +++-- .../agent/providers/mock-load-test-agent.ts | 20 +- .../agent/providers/mock-slow-provider.ts | 8 - .../agent/providers/opencode-agent.test.ts | 123 ++++++++---- .../server/agent/providers/opencode-agent.ts | 107 ++++++----- .../test-utils/test-opencode-runtime.ts | 8 + .../server/agent/providers/pi/agent.test.ts | 30 +-- .../src/server/agent/providers/pi/agent.ts | 28 ++- .../agent/providers/pi/session-descriptor.ts | 76 +++----- .../opencode-draft-features.real.e2e.test.ts | 23 ++- packages/server/src/server/session.ts | 1 - .../src/server/session.workspaces.test.ts | 78 ++++---- 28 files changed, 1035 insertions(+), 744 deletions(-) create mode 100644 packages/server/src/server/agent/provider-session-import.ts diff --git a/docs/providers.md b/docs/providers.md index 4ff13447c..16c0ea14f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -36,6 +36,8 @@ Every provider adapter owns its canonical user-message timeline rows. When a for Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. +Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready. + --- ## Provider Snapshot Refresh Contract @@ -315,7 +317,13 @@ interface AgentClient { isAvailable(): Promise; // Optional: listModes?(options: ListModesOptions): Promise; - listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; + listImportableSessions?( + options?: ListImportableSessionsOptions, + ): Promise; + importSession?( + input: ImportProviderSessionInput, + context: ImportProviderSessionContext, + ): Promise; getDiagnostic?(): Promise<{ diagnostic: string }>; } ``` diff --git a/packages/app/src/components/import-session-sheet.test.tsx b/packages/app/src/components/import-session-sheet.test.tsx index 3d5cc3915..0f2820ba7 100644 --- a/packages/app/src/components/import-session-sheet.test.tsx +++ b/packages/app/src/components/import-session-sheet.test.tsx @@ -71,7 +71,9 @@ vi.mock("lucide-react-native", () => { return Icon; }; return { + ChevronDown: icon("ChevronDown"), Inbox: icon("Inbox"), + Layers: icon("Layers"), RotateCw: icon("RotateCw"), }; }); @@ -81,35 +83,38 @@ vi.mock("@/components/ui/loading-spinner", () => ({ React.createElement("span", { "data-testid": "import-session-loading-spinner" }), })); -vi.mock("@/components/ui/segmented-control", () => ({ - SegmentedControl: ({ +vi.mock("@/components/ui/combobox", () => ({ + Combobox: ({ options, value, - onValueChange, - testID, + onSelect, + open, }: { - options: ReadonlyArray<{ value: string; label: string; testID?: string }>; + options: ReadonlyArray<{ id: string; label: string }>; value: string; - onValueChange: (value: string) => void; - testID?: string; - }) => - React.createElement( + onSelect: (id: string) => void; + open?: boolean; + }) => { + if (!open) return null; + return React.createElement( "div", - { "data-testid": testID }, + { "data-testid": "import-session-combobox" }, options.map((option) => React.createElement( "button", { - key: option.value, + key: option.id, type: "button", - "data-testid": option.testID, - "data-selected": value === option.value, - onClick: () => onValueChange(option.value), + "data-testid": `import-session-filter-${option.id === "__all__" ? "all" : option.id}`, + "data-selected": value === option.id, + onClick: () => onSelect(option.id), }, option.label, ), ), - ), + ); + }, + ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label), })); vi.mock("@/components/adaptive-modal-sheet", () => ({ @@ -475,7 +480,13 @@ describe("ImportSessionSheet", () => { it("imports a selected session by provider handle and reports the imported agent", async () => { const fetchRecentProviderSessions = vi.fn(async () => ({ requestId: "recent-provider-sessions", - entries: [createProviderSessionEntry({ providerId: "claude", providerLabel: "Claude Code" })], + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerLabel: "Claude Code", + cwd: "/repo/paseo-realpath", + }), + ], })); const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported")); const onClose = vi.fn(); @@ -499,7 +510,7 @@ describe("ImportSessionSheet", () => { expect(importAgent).toHaveBeenCalledWith({ providerId: "claude", providerHandleId: "provider-thread-1", - cwd: "/repo/paseo", + cwd: "/repo/paseo-realpath", }); }); expect(onImportedAgent).toHaveBeenCalledWith("agent-imported"); @@ -678,11 +689,13 @@ describe("ImportSessionSheet", () => { await screen.findByText("Session claude"); await screen.findByText("Session codex"); + fireEvent.click(screen.getByTestId("import-session-filter-trigger")); fireEvent.click(screen.getByTestId("import-session-filter-codex")); screen.getByText("Session codex"); expect(screen.queryByText("Session claude")).toBeNull(); + fireEvent.click(screen.getByTestId("import-session-filter-trigger")); fireEvent.click(screen.getByTestId("import-session-filter-all")); screen.getByText("Session claude"); diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index b85976c88..6a8b9138b 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -1,16 +1,16 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import type { DaemonClient, FetchRecentProviderSessionEntry, } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import { Inbox, RotateCw } from "lucide-react-native"; +import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; +import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { getProviderIcon } from "@/components/provider-icons"; import { formatTimeAgo } from "@/utils/time"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; @@ -88,6 +88,7 @@ interface SheetStatusMessagesProps { isSnapshotUnsupported: boolean; hasNoImportableProviders: boolean; isLoadingSessions: boolean; + hasRows: boolean; allQueriesErrored: boolean; erroredProviderLabels: ReadonlyArray; importErrored: boolean; @@ -98,6 +99,7 @@ function SheetStatusMessages({ isSnapshotUnsupported, hasNoImportableProviders, isLoadingSessions, + hasRows, allQueriesErrored, erroredProviderLabels, importErrored, @@ -114,7 +116,7 @@ function SheetStatusMessages({ {hasNoImportableProviders ? ( No importable providers are enabled. ) : null} - {isLoadingSessions ? ( + {isLoadingSessions && !hasRows ? ( Loading recent sessions... @@ -176,25 +178,6 @@ function SheetEmptyState({ title }: { title: string }) { ); } -function buildProviderFilterOptions( - providers: ReadonlyArray, - providerLabelById: ReadonlyMap, -): SegmentedControlOption[] { - const options: SegmentedControlOption[] = [ - { value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" }, - ]; - for (const provider of providers) { - const ProviderIcon = getProviderIcon(provider); - options.push({ - value: provider, - label: providerLabelById.get(provider) ?? provider, - testID: `import-session-filter-${provider}`, - icon: ({ color, size }) => , - }); - } - return options; -} - function ImportSessionSheetRow({ entry, disabled, @@ -271,6 +254,7 @@ export function ImportSessionSheet({ onImported, }: ImportSessionSheetProps) { const queryClient = useQueryClient(); + const { theme } = useUnistyles(); const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, { cwd, @@ -315,6 +299,8 @@ export function ImportSessionSheet({ const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]); const [selectedProvider, setSelectedProvider] = useState(ALL_FILTER_VALUE); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const filterAnchorRef = useRef(null); useEffect(() => { if ( @@ -330,24 +316,84 @@ export function ImportSessionSheet({ return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider); }, [aggregatedEntries, selectedProvider]); - const filterOptions = useMemo( - () => buildProviderFilterOptions(filterProviders, providerLabelById), + const filterComboboxOptions = useMemo( + () => [ + { id: ALL_FILTER_VALUE, label: "All providers" }, + ...filterProviders.map((provider) => ({ + id: provider, + label: providerLabelById.get(provider) ?? provider, + })), + ], [filterProviders, providerLabelById], ); + const selectedProviderLabel = useMemo( + () => + filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers", + [filterComboboxOptions, selectedProvider], + ); + + const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []); + + const filterTriggerStyle = useCallback( + ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.filterTrigger, + Boolean(hovered) && styles.filterTriggerHovered, + pressed && styles.filterTriggerPressed, + ], + [], + ); + + const handleFilterSelect = useCallback((id: string) => { + setSelectedProvider(id); + setIsFilterOpen(false); + }, []); + + const filterOptionIcons = useMemo(() => { + const map = new Map(); + map.set(ALL_FILTER_VALUE, ); + for (const provider of filterProviders) { + const ProviderIcon = getProviderIcon(provider); + map.set(provider, ); + } + return map; + }, [filterProviders, theme.colors.foregroundMuted]); + + const renderFilterOption = useCallback( + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => ( + + ), + [filterOptionIcons], + ); + const importMutation = useMutation({ mutationFn: async (entry: FetchRecentProviderSessionEntry) => { if (!client) { throw new Error("Host is not connected"); } - const effectiveCwd = cwd ?? entry.cwd; - if (!effectiveCwd) { + if (!entry.cwd) { throw new Error("Session is missing a working directory"); } const agent = await client.importAgent({ providerId: entry.providerId, providerHandleId: entry.providerHandleId, - cwd: effectiveCwd, + cwd: entry.cwd, }); return agent; }, @@ -423,25 +469,48 @@ export function ImportSessionSheet({ snapPoints={IMPORT_SHEET_SNAP_POINTS} > {showFilter ? ( - - + + {selectedProvider === ALL_FILTER_VALUE ? ( + + ) : ( + (() => { + const ProviderIcon = getProviderIcon(selectedProvider); + return ; + })() + )} + + {selectedProviderLabel} + + + + - + ) : null} 0} allQueriesErrored={allQueriesErrored} erroredProviderLabels={erroredProviderLabels} importErrored={importMutation.isError} @@ -466,10 +535,32 @@ export function ImportSessionSheet({ } const styles = StyleSheet.create((theme) => ({ - filterRow: { - flexDirection: "row", + filterTriggerWrap: { paddingBottom: theme.spacing[2], }, + filterTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + alignSelf: "flex-start", + paddingVertical: theme.spacing[1.5], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + filterTriggerHovered: { + backgroundColor: theme.colors.surface2, + }, + filterTriggerPressed: { + backgroundColor: theme.colors.surface3, + }, + filterTriggerText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, list: { gap: theme.spacing[1], }, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 16b54e4d6..e05cb4cd7 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import { AgentManager, type ManagedAgent } from "./agent-manager.js"; +import { AgentManager, type AgentManagerEvent, type ManagedAgent } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { formatSystemNotificationPrompt } from "./agent-prompt.js"; @@ -25,7 +25,7 @@ import type { AgentSessionConfig, AgentStreamEvent, AgentTimelineItem, - PersistedAgentDescriptor, + ImportProviderSessionInput, } from "./agent-sdk-types.js"; import type { ProviderDefinition } from "./provider-registry.js"; @@ -48,6 +48,7 @@ function deferred(): Deferred { const TEST_CAPABILITIES = { supportsStreaming: false, supportsSessionPersistence: false, + supportsSessionListing: true, supportsDynamicModes: false, supportsMcpServers: false, supportsReasoningStream: false, @@ -63,30 +64,6 @@ function createFeature(args: { id: string; label: string; value: boolean }): Age }; } -function createPersistedDescriptor(args: { - cwd: string; - sessionId: string; - nativeHandle?: string; -}): PersistedAgentDescriptor { - return { - provider: "codex", - sessionId: args.sessionId, - cwd: args.cwd, - title: null, - lastActivityAt: new Date("2026-01-01T00:00:00Z"), - persistence: { - provider: "codex", - sessionId: args.sessionId, - nativeHandle: args.nativeHandle, - metadata: { - provider: "codex", - cwd: args.cwd, - }, - }, - timeline: [], - }; -} - function expectArchivedAgentRecord( record: StoredAgentRecord | null, expectedLastStatus: "closed" | "idle", @@ -1471,36 +1448,48 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p }); }); -test("findPersistedAgent returns matching descriptors by session id or native handle", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-find-persisted-")); +test("importProviderSession imports the selected session without listing and publishes ready state", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); + const session = new TestAgentSession({ provider: "codex", cwd: workdir }); + const events: AgentManagerEvent[] = []; - const descriptors: PersistedAgentDescriptor[] = [ - createPersistedDescriptor({ - cwd: workdir, - sessionId: "session-direct", - nativeHandle: "native-direct", - }), - createPersistedDescriptor({ - cwd: workdir, - sessionId: "session-other", - nativeHandle: "native-match", - }), - ]; + class ImportClient extends TestAgentClient { + listCalls = 0; + importInput: unknown = null; - class PersistedAgentsClient extends TestAgentClient { - lastLimit: number | undefined; - lastCwd: string | undefined; + async listImportableSessions() { + this.listCalls += 1; + return []; + } - override async listPersistedAgents(options?: { limit?: number; cwd?: string }) { - this.lastLimit = options?.limit; - this.lastCwd = options?.cwd; - return descriptors; + async importSession(input: ImportProviderSessionInput) { + this.importInput = input; + return { + session, + config: { provider: "codex" as const, cwd: workdir }, + persistence: { + provider: "codex" as const, + sessionId: input.providerHandleId, + nativeHandle: input.providerHandleId, + metadata: { provider: "codex", cwd: workdir }, + }, + timeline: [ + { + item: { type: "user_message" as const, text: "Trace provider imports" }, + timestamp: "2026-01-02T00:00:00.000Z", + }, + { + item: { type: "assistant_message" as const, text: "Done" }, + timestamp: "2026-01-02T00:00:01.000Z", + }, + ], + }; } } - const client = new PersistedAgentsClient(); + const client = new ImportClient(); const manager = new AgentManager({ clients: { codex: client, @@ -1508,15 +1497,32 @@ test("findPersistedAgent returns matching descriptors by session id or native ha registry: storage, logger, }); + manager.subscribe((event) => events.push(event), { replayState: false }); - await expect(manager.findPersistedAgent("codex", "session-direct")).resolves.toBe(descriptors[0]); - await expect(manager.findPersistedAgent("codex", "native-match")).resolves.toBe(descriptors[1]); - await expect(manager.findPersistedAgent("codex", "missing")).resolves.toBeNull(); - await expect( - manager.findPersistedAgent("codex", "session-direct", { cwd: "/tmp/project" }), - ).resolves.toBe(descriptors[0]); - expect(client.lastLimit).toBe(200); - expect(client.lastCwd).toBe("/tmp/project"); + const imported = await manager.importProviderSession({ + provider: "codex", + providerHandleId: "thread-selected", + cwd: workdir, + }); + + expect(client.listCalls).toBe(0); + expect(client.importInput).toEqual({ providerHandleId: "thread-selected", cwd: workdir }); + expect(imported.lifecycle).toBe("idle"); + expect(imported.historyPrimed).toBe(true); + expect(manager.getTimeline(imported.id)).toEqual([ + { type: "user_message", text: "Trace provider imports" }, + { type: "assistant_message", text: "Done" }, + ]); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "agent_state", + agent: { + id: imported.id, + lifecycle: "idle", + persistence: { nativeHandle: "thread-selected" }, + }, + }); + expect((await storage.get(imported.id))?.title).toBe("Trace provider imports"); }); test("reloadAgentSession passes daemon launch env through the provider launch context", async () => { @@ -5711,17 +5717,16 @@ class RecordingPersistedAgentsClient implements AgentClient { return []; } - async listPersistedAgents(): Promise { + async listImportableSessions() { this.calls += 1; return [ { - provider: this.provider, - sessionId: `${this.provider}-session`, + providerHandleId: `${this.provider}-session`, cwd: "/tmp/recent", title: null, lastActivityAt: new Date("2026-01-01T00:00:00Z"), - persistence: { provider: this.provider, sessionId: `${this.provider}-session` }, - timeline: [], + firstPromptPreview: null, + lastPromptPreview: null, }, ]; } @@ -5738,7 +5743,7 @@ test.each([ }, ], ])( - "listImportablePersistedAgents skips %s providers in fan-out", + "listImportableSessions skips %s providers in fan-out", async (_reason, includedProvider, skippedProvider, providerDefinitions) => { const includedClient = new RecordingPersistedAgentsClient(includedProvider); const skippedClient = new RecordingPersistedAgentsClient(skippedProvider); @@ -5748,7 +5753,7 @@ test.each([ logger, }); - const result = await manager.listImportablePersistedAgents(); + const result = await manager.listImportableSessions(); expect(includedClient.calls).toBe(1); expect(skippedClient.calls).toBe(0); @@ -5756,7 +5761,7 @@ test.each([ }, ); -test("listImportablePersistedAgents includes derived providers that list persisted agents", async () => { +test("listImportableSessions includes derived providers that list persisted agents", async () => { const claudeClient = new RecordingPersistedAgentsClient("claude"); const ompClient = new RecordingPersistedAgentsClient("omp"); const manager = new AgentManager({ @@ -5768,14 +5773,14 @@ test("listImportablePersistedAgents includes derived providers that list persist logger, }); - const result = await manager.listImportablePersistedAgents(); + const result = await manager.listImportableSessions(); expect(claudeClient.calls).toBe(1); expect(ompClient.calls).toBe(1); expect(result.map((d) => d.provider).sort()).toEqual(["claude", "omp"]); }); -test("listImportablePersistedAgents narrows to the providerFilter when supplied", async () => { +test("listImportableSessions narrows to the providerFilter when supplied", async () => { const claudeClient = new RecordingPersistedAgentsClient("claude"); const codexClient = new RecordingPersistedAgentsClient("codex"); const manager = new AgentManager({ @@ -5787,7 +5792,7 @@ test("listImportablePersistedAgents narrows to the providerFilter when supplied" logger, }); - const result = await manager.listImportablePersistedAgents({ + const result = await manager.listImportableSessions({ providerFilter: new Set(["claude"]), }); @@ -5796,6 +5801,33 @@ test("listImportablePersistedAgents narrows to the providerFilter when supplied" expect(result.map((d) => d.provider)).toEqual(["claude"]); }); +test("listImportableSessions skips providers that lack supportsSessionListing even when row listing is defined", async () => { + const listableClient = new RecordingPersistedAgentsClient("claude"); + const nonListableClient = new RecordingPersistedAgentsClient("acp"); + // Override capabilities to remove session listing support + Object.defineProperty(nonListableClient, "capabilities", { + value: { + ...TEST_CAPABILITIES, + supportsSessionListing: false, + }, + }); + + const manager = new AgentManager({ + clients: { claude: listableClient, acp: nonListableClient }, + providerDefinitions: { + claude: { enabled: true, derivedFromProviderId: null }, + acp: { enabled: true, derivedFromProviderId: null }, + }, + logger, + }); + + const result = await manager.listImportableSessions(); + + expect(listableClient.calls).toBe(1); + expect(nonListableClient.calls).toBe(0); + expect(result.map((d) => d.provider)).toEqual(["claude"]); +}); + test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index a7fb16986..ec8c7bd0c 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -33,8 +33,9 @@ import { type AgentTimelineItem, type AgentUsage, type AgentRuntimeInfo, - type ListPersistedAgentsOptions, - type PersistedAgentDescriptor, + type ImportedTimelineEntry, + type ImportableProviderSession, + type ListImportableSessionsOptions, } from "./agent-sdk-types.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; @@ -57,6 +58,7 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { isSystemInjectedEnvelope } from "./agent-prompt.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; +import { resolveCreateAgentTitles } from "./create-agent-title.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; @@ -146,7 +148,7 @@ interface HydrateTimelineOptions { broadcast?: boolean; } -export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & { +export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & { /** * When set, only providers in this set are scanned, in addition to the * built-in importable allowlist + enabled + non-derived rules. @@ -154,6 +156,10 @@ export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & providerFilter?: Set; }; +export interface ManagedImportableProviderSession extends ImportableProviderSession { + provider: AgentProvider; +} + export type AgentAttentionCallback = (params: { agentId: string; provider: AgentProvider; @@ -415,6 +421,50 @@ function buildExplicitTimelineSeedForRegister( }; } +function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] { + const rows: AgentTimelineRow[] = []; + for (const entry of entries) { + if (entry.item.type === "user_message" && isSystemInjectedEnvelope(entry.item.text)) { + continue; + } + rows.push({ + seq: rows.length + 1, + timestamp: entry.timestamp ?? new Date().toISOString(), + item: entry.item, + }); + } + return rows; +} + +function resolveImportedAgentTitle( + config: AgentSessionConfig, + timelineRows: readonly AgentTimelineRow[], +): string | null { + const initialPrompt = getFirstUserMessageTextFromRows(timelineRows); + if (!initialPrompt) { + return null; + } + const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({ + configTitle: config.title, + initialPrompt, + }); + return explicitTitle ?? provisionalTitle ?? null; +} + +function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): string | null { + for (const row of rows) { + const item = row.item; + if (item.type !== "user_message") { + continue; + } + const text = item.text.trim(); + if (text) { + return text; + } + } + return null; +} + export class AgentManager { private readonly clients = new Map(); private readonly providerEnabled = new Map(); @@ -612,34 +662,37 @@ export class AgentManager { .map((agent) => Object.assign({}, agent)); } - async listImportablePersistedAgents( + async listImportableSessions( options?: ImportablePersistedAgentQueryOptions, - ): Promise { + ): Promise { const providerEntries = Array.from(this.clients.entries()).filter( ([provider, client]) => - !!client.listPersistedAgents && + client.capabilities.supportsSessionListing && + !!client.listImportableSessions && this.isProviderImportable(provider, options?.providerFilter), ); - const descriptorLists = await Promise.all( + const sessionLists = await Promise.all( providerEntries.map(async ([provider, client]) => { try { - return await client.listPersistedAgents!({ - limit: options?.limit, - cwd: options?.cwd, - }); + return ( + await client.listImportableSessions!({ + limit: options?.limit, + cwd: options?.cwd, + }) + ).map((session) => Object.assign(session, { provider })); } catch (error) { this.logger.warn( { err: error, provider }, - "Failed to list persisted agents for provider", + "Failed to list importable sessions for provider", ); return []; } }), ); - const descriptors: PersistedAgentDescriptor[] = descriptorLists.flat(); + const sessions: ManagedImportableProviderSession[] = sessionLists.flat(); const limit = options?.limit ?? 20; - return descriptors + return sessions .sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()) .slice(0, limit); } @@ -657,26 +710,6 @@ export class AgentManager { return true; } - async findPersistedAgent( - provider: AgentProvider, - sessionId: string, - options?: Pick, - ): Promise { - const client = this.requireClient(provider); - if (!client.listPersistedAgents) { - return null; - } - - const descriptors = await client.listPersistedAgents({ limit: 200, cwd: options?.cwd }); - return ( - descriptors.find((descriptor) => { - return ( - descriptor.sessionId === sessionId || descriptor.persistence.nativeHandle === sessionId - ); - }) ?? null - ); - } - async listProviderAvailability(): Promise { const checks = Array.from(this.clients.keys()).map(async (provider) => { const client = this.clients.get(provider); @@ -871,6 +904,50 @@ export class AgentManager { return this.registerSession(session, storedConfig, resolvedAgentId, options); } + async importProviderSession(input: { + provider: AgentProvider; + providerHandleId: string; + cwd: string; + labels?: Record; + }): Promise { + const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession"); + this.requireEnabledProvider(input.provider); + + const client = await this.requireAvailableClient({ provider: input.provider }); + if (!client.importSession) { + throw new Error(`Provider '${input.provider}' does not support importing sessions`); + } + + const { storedConfig, launchConfig } = await this.prepareSessionConfig( + { + provider: input.provider, + cwd: input.cwd, + }, + resolvedAgentId, + ); + const launchContext = this.buildLaunchContext(resolvedAgentId); + const imported = await client.importSession( + { + providerHandleId: input.providerHandleId, + cwd: input.cwd, + }, + { config: launchConfig, storedConfig, launchContext }, + ); + const importedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(imported.config)); + const timelineRows = buildImportedTimelineRows(imported.timeline); + const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows); + + return this.registerSession(imported.session, importedConfig, resolvedAgentId, { + labels: input.labels, + timelineRows, + timelineNextSeq: timelineRows.length + 1, + persistence: imported.persistence, + historyPrimed: true, + initialTitle, + publishWhenReady: true, + }); + } + // Hot-reload an active agent session with config overrides. By default the // in-memory timeline is preserved (used for voice-mode toggles and similar // config swaps). When `rehydrateFromDisk` is set, the timeline is wiped so a @@ -2236,11 +2313,13 @@ export class AgentManager { timeline?: AgentTimelineItem[]; timelineRows?: AgentTimelineRow[]; timelineNextSeq?: number; + persistence?: AgentPersistenceHandle; historyPrimed?: boolean; lastUsage?: AgentUsage; lastError?: string; attention?: AttentionState; initialTitle?: string | null; + publishWhenReady?: boolean; }, ): Promise { const resolvedAgentId = validateAgentId(agentId, "registerSession"); @@ -2272,14 +2351,16 @@ export class AgentManager { this.agents.set(resolvedAgentId, managed); // Initialize previousStatus to track transitions this.previousStatuses.set(resolvedAgentId, managed.lifecycle); - await this.refreshRuntimeInfo(managed); + await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady }); await this.persistSnapshot(managed, { workspaceId: options?.workspaceId, title: initialPersistedTitle, }); - this.emitState(managed, { persist: false }); + if (!options?.publishWhenReady) { + this.emitState(managed, { persist: false }); + } - await this.refreshSessionState(managed); + await this.refreshSessionState(managed, { emit: !options?.publishWhenReady }); managed.lifecycle = "idle"; await this.persistSnapshot(managed, { workspaceId: options?.workspaceId }); this.emitState(managed, { persist: false }); @@ -2295,6 +2376,7 @@ export class AgentManager { timeline?: AgentTimelineItem[]; timelineRows?: AgentTimelineRow[]; timelineNextSeq?: number; + persistence?: AgentPersistenceHandle; createdAt?: Date; updatedAt?: Date; } @@ -2337,6 +2419,7 @@ export class AgentManager { lastUsage?: AgentUsage; lastError?: string; attention?: AttentionState; + persistence?: AgentPersistenceHandle; } | undefined; }): ActiveManagedAgent { @@ -2362,7 +2445,10 @@ export class AgentManager { foregroundTurnWaiters: new Set(), finalizedForegroundTurnIds: new Set(), unsubscribeSession: null, - persistence: attachPersistenceCwd(session.describePersistence(), config.cwd), + persistence: attachPersistenceCwd( + options?.persistence ?? session.describePersistence(), + config.cwd, + ), historyPrimed: options?.historyPrimed ?? durableTimelineHasRows, lastUserMessageAt: options?.lastUserMessageAt ?? null, lastUsage: options?.lastUsage, @@ -2560,7 +2646,10 @@ export class AgentManager { return this.registry; } - private async refreshSessionState(agent: ActiveManagedAgent): Promise { + private async refreshSessionState( + agent: ActiveManagedAgent, + options?: { emit?: boolean }, + ): Promise { try { const modes = await agent.session.getAvailableModes(); agent.availableModes = modes; @@ -2582,10 +2671,13 @@ export class AgentManager { } this.syncFeaturesFromSession(agent); - await this.refreshRuntimeInfo(agent); + await this.refreshRuntimeInfo(agent, options); } - private async refreshRuntimeInfo(agent: ActiveManagedAgent): Promise { + private async refreshRuntimeInfo( + agent: ActiveManagedAgent, + options?: { emit?: boolean }, + ): Promise { try { const newInfo = await agent.session.getRuntimeInfo(); const changed = @@ -2601,7 +2693,7 @@ export class AgentManager { ); } // Emit state if runtimeInfo changed so clients get the updated model - if (changed) { + if (changed && options?.emit !== false) { this.emitState(agent); } } catch { diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index 5fb6ca975..c516b84f4 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -10,10 +10,10 @@ import { import type { AgentSession } from "./agent-sdk-types.js"; import type { AgentFeature, + ImportableProviderSession, AgentPermissionRequest, AgentPersistenceHandle, AgentSessionConfig, - PersistedAgentDescriptor, } from "./agent-sdk-types.js"; type ManagedAgentOverrides = Omit, "config" | "pendingPermissions"> & { @@ -400,26 +400,18 @@ describe("toAgentPayload", () => { }); describe("toRecentProviderSessionDescriptorPayload", () => { - it("projects persisted descriptors to provider-opaque public recent sessions", () => { - const descriptor: PersistedAgentDescriptor = { + it("projects provider import rows to provider-opaque public recent sessions", () => { + const session: ImportableProviderSession & { provider: string } = { provider: "codex-custom", - sessionId: "legacy-session-id", + providerHandleId: "provider-native-handle", cwd: "/tmp/project", title: "Import me", + firstPromptPreview: "First prompt with spacing", + lastPromptPreview: "Second prompt", lastActivityAt: new Date("2026-04-30T12:34:56.000Z"), - persistence: { - provider: "codex-custom", - sessionId: "legacy-session-id", - nativeHandle: "provider-native-handle", - }, - timeline: [ - { type: "assistant_message", text: "Ready" }, - { type: "user_message", text: " First prompt\n\nwith spacing " }, - { type: "user_message", text: "Second prompt" }, - ], }; - const payload = toRecentProviderSessionDescriptorPayload(descriptor, { + const payload = toRecentProviderSessionDescriptorPayload(session, { providerLabel: "Custom Codex", }); @@ -438,28 +430,25 @@ describe("toRecentProviderSessionDescriptorPayload", () => { expect(payload).not.toHaveProperty("nativeHandle"); }); - it("falls back to persistence session id when no provider native handle exists", () => { - const descriptor: PersistedAgentDescriptor = { + it("preserves null prompt previews", () => { + const session: ImportableProviderSession & { provider: string } = { provider: "claude-custom", - sessionId: "descriptor-session-id", + providerHandleId: "provider-session-id", cwd: "/tmp/project", title: null, lastActivityAt: new Date("2026-04-30T12:34:56.000Z"), - persistence: { - provider: "claude-custom", - sessionId: "persistence-session-id", - }, - timeline: [], + firstPromptPreview: null, + lastPromptPreview: null, }; expect( - toRecentProviderSessionDescriptorPayload(descriptor, { + toRecentProviderSessionDescriptorPayload(session, { providerLabel: "Custom Claude", }), ).toMatchObject({ providerId: "claude-custom", providerLabel: "Custom Claude", - providerHandleId: "persistence-session-id", + providerHandleId: "provider-session-id", firstPromptPreview: null, lastPromptPreview: null, }); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 28eb35378..8ac6650e7 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -14,9 +14,8 @@ import type { AgentProvider, AgentSessionConfig, AgentRuntimeInfo, - AgentTimelineItem, AgentUsage, - PersistedAgentDescriptor, + ImportableProviderSession, } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { JsonValue } from "../json-utils.js"; @@ -33,8 +32,6 @@ interface RecentProviderSessionProjectionOptions { providerLabel: string; } -const PROMPT_PREVIEW_MAX_LENGTH = 160; - function normalizeThinkingOptionId(value: string | null | undefined): string | null { if (typeof value !== "string") return null; const normalized = value.trim(); @@ -263,20 +260,18 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt } export function toRecentProviderSessionDescriptorPayload( - descriptor: PersistedAgentDescriptor, + session: ImportableProviderSession & { provider: string }, options: RecentProviderSessionProjectionOptions, ): RecentProviderSessionDescriptorPayload { - const promptPreviews = collectPromptPreviews(descriptor.timeline); - return { - providerId: descriptor.provider, + providerId: session.provider, providerLabel: options.providerLabel, - providerHandleId: descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId, - cwd: descriptor.cwd, - title: descriptor.title, - firstPromptPreview: promptPreviews[0] ?? null, - lastPromptPreview: promptPreviews.at(-1) ?? null, - lastActivityAt: descriptor.lastActivityAt.toISOString(), + providerHandleId: session.providerHandleId, + cwd: session.cwd, + title: session.title, + firstPromptPreview: session.firstPromptPreview, + lastPromptPreview: session.lastPromptPreview, + lastActivityAt: session.lastActivityAt.toISOString(), }; } @@ -297,26 +292,6 @@ export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): s return timestamps[0].raw; } -function collectPromptPreviews(timeline: readonly AgentTimelineItem[]): string[] { - return timeline.flatMap((item) => { - if (item.type !== "user_message") { - return []; - } - const preview = normalizePromptPreview(item.text); - return preview ? [preview] : []; - }); -} - -function normalizePromptPreview(text: string): string | null { - const normalized = text.trim().replace(/\s+/g, " "); - if (!normalized) { - return null; - } - return normalized.length > PROMPT_PREVIEW_MAX_LENGTH - ? normalized.slice(0, PROMPT_PREVIEW_MAX_LENGTH) - : normalized; -} - function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null { const serializable: SerializableAgentConfig = {}; if (config.modeId) { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index b99f85dcc..61869e43f 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -162,6 +162,7 @@ export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect; export interface AgentCapabilityFlags { supportsStreaming: boolean; supportsSessionPersistence: boolean; + supportsSessionListing?: boolean; supportsDynamicModes: boolean; supportsMcpServers: boolean; supportsReasoningStream: boolean; @@ -494,25 +495,45 @@ export interface AgentSlashCommand { kind?: AgentSlashCommandKind; } -export interface ListPersistedAgentsOptions { +export interface ListImportableSessionsOptions { limit?: number; /** - * Optional cwd hint. Providers that can cheaply pre-filter persisted - * sessions by working directory should do so before doing expensive - * work like fetching turn timelines. Providers that can't filter - * cheaply may ignore this hint. + * Optional cwd hint. Providers that can cheaply pre-filter importable + * sessions by working directory should do so before doing expensive work. */ cwd?: string; } -export interface PersistedAgentDescriptor { - provider: AgentProvider; - sessionId: string; +export interface ImportableProviderSession { + providerHandleId: string; cwd: string; title: string | null; + firstPromptPreview: string | null; + lastPromptPreview: string | null; lastActivityAt: Date; +} + +export interface ImportProviderSessionInput { + providerHandleId: string; + cwd: string; +} + +export interface ImportProviderSessionContext { + config: AgentSessionConfig; + storedConfig: AgentSessionConfig; + launchContext?: AgentLaunchContext; +} + +export interface ImportedTimelineEntry { + item: AgentTimelineItem; + timestamp?: string; +} + +export interface ImportedProviderSession { + session: AgentSession; + config: AgentSessionConfig; persistence: AgentPersistenceHandle; - timeline: AgentTimelineItem[]; + timeline: ImportedTimelineEntry[]; } export interface AgentSessionConfig { @@ -640,7 +661,13 @@ export interface AgentClient { isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean; listCommands?(config: AgentSessionConfig): Promise; listFeatures?(config: AgentSessionConfig): Promise; - listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; + listImportableSessions?( + options?: ListImportableSessionsOptions, + ): Promise; + importSession?( + input: ImportProviderSessionInput, + context: ImportProviderSessionContext, + ): Promise; /** * Check if this provider is available (CLI binary is installed). * Returns true if available, false otherwise. diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index 8e82d7355..825eca3a2 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -2,10 +2,14 @@ import { beforeEach, expect, test, vi } from "vitest"; import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { AgentManager, ManagedAgent } from "./agent-manager.js"; +import type { + AgentManager, + ManagedAgent, + ManagedImportableProviderSession, +} from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { FetchRecentProviderSessionsRequestMessage } from "@getpaseo/protocol/messages"; -import type { AgentTimelineItem, PersistedAgentDescriptor } from "./agent-sdk-types.js"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { ImportSessionsRequestError, importProviderSession, @@ -28,7 +32,7 @@ beforeEach(() => { vi.clearAllMocks(); }); -function makeDescriptor(args: { +function makeImportableSession(args: { provider?: string; sessionId: string; nativeHandle?: string; @@ -37,25 +41,17 @@ function makeDescriptor(args: { lastActivityAt: string; firstPrompt?: string; lastPrompt?: string; -}): PersistedAgentDescriptor { +}): ManagedImportableProviderSession { const provider = args.provider ?? "codex"; const cwd = args.cwd ?? "/tmp/project"; return { provider, - sessionId: args.sessionId, + providerHandleId: args.nativeHandle ?? args.sessionId, cwd, title: args.title ?? null, lastActivityAt: new Date(args.lastActivityAt), - persistence: { - provider, - sessionId: args.sessionId, - ...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}), - metadata: { provider, cwd }, - }, - timeline: [ - ...(args.firstPrompt ? [{ type: "user_message" as const, text: args.firstPrompt }] : []), - ...(args.lastPrompt ? [{ type: "user_message" as const, text: args.lastPrompt }] : []), - ], + firstPromptPreview: args.firstPrompt ?? null, + lastPromptPreview: args.lastPrompt ?? args.firstPrompt ?? null, }; } @@ -114,15 +110,15 @@ function makeRequest( test("listImportableProviderSessions filters, sorts, limits, and projects importable sessions", async () => { const cwd = "/tmp/project"; - const descriptors = [ - makeDescriptor({ + const sessions = [ + makeImportableSession({ sessionId: "outside-cwd", nativeHandle: "outside-cwd-handle", cwd: "/tmp/elsewhere", title: "Outside cwd", lastActivityAt: "2026-04-30T12:05:00.000Z", }), - makeDescriptor({ + makeImportableSession({ sessionId: "stored-session", nativeHandle: "stored-handle", cwd, @@ -130,14 +126,14 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import lastActivityAt: "2026-04-30T12:04:00.000Z", firstPrompt: "stored prompt", }), - makeDescriptor({ + makeImportableSession({ sessionId: "older-session", nativeHandle: "older-handle", cwd, title: "Older than since", lastActivityAt: "2026-04-29T23:59:59.000Z", }), - makeDescriptor({ + makeImportableSession({ sessionId: "newer-session", nativeHandle: "newer-handle", cwd, @@ -146,7 +142,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import firstPrompt: "newer first prompt", lastPrompt: "newer last prompt", }), - makeDescriptor({ + makeImportableSession({ sessionId: "second-session", nativeHandle: "second-handle", cwd, @@ -154,7 +150,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import lastActivityAt: "2026-04-30T12:00:00.000Z", firstPrompt: "second prompt", }), - makeDescriptor({ + makeImportableSession({ sessionId: "third-session", nativeHandle: "third-handle", cwd, @@ -162,7 +158,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import lastActivityAt: "2026-04-30T11:59:00.000Z", firstPrompt: "third prompt", }), - makeDescriptor({ + makeImportableSession({ sessionId: "live-session", nativeHandle: "live-handle", cwd, @@ -171,7 +167,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import firstPrompt: "live prompt", }), ]; - const listImportablePersistedAgents = vi.fn(async () => descriptors); + const listImportableSessions = vi.fn(async () => sessions); const agentManager = { listAgents: () => [ @@ -184,8 +180,8 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import }, }, ] as ManagedAgent[], - listImportablePersistedAgents, - } satisfies Pick; + listImportableSessions, + } satisfies Pick; const agentStorage = { list: async () => [ { @@ -211,7 +207,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import providerSnapshotManager: { getProviderLabel: () => "Codex" }, }); - expect(listImportablePersistedAgents).toHaveBeenCalledWith({ + expect(listImportableSessions).toHaveBeenCalledWith({ limit: 2, providerFilter: new Set(["codex"]), cwd, @@ -245,8 +241,8 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import test("listImportableProviderSessions filters out metadata generation sessions", async () => { const cwd = "/tmp/project"; - const descriptors = [ - makeDescriptor({ + const sessions = [ + makeImportableSession({ sessionId: "metadata-session", nativeHandle: "metadata-handle", cwd, @@ -255,7 +251,7 @@ test("listImportableProviderSessions filters out metadata generation sessions", firstPrompt: "Generate metadata for a coding agent based on the user prompt.\nTitle: short descriptive label (<= 40 chars).", }), - makeDescriptor({ + makeImportableSession({ sessionId: "real-session", nativeHandle: "real-handle", cwd, @@ -269,8 +265,8 @@ test("listImportableProviderSessions filters out metadata generation sessions", request: makeRequest({ cwd, providers: ["codex"] }), agentManager: { listAgents: () => [], - listImportablePersistedAgents: async () => descriptors, - } satisfies Pick, + listImportableSessions: async () => sessions, + } satisfies Pick, agentStorage: { list: async () => [], } satisfies Pick, @@ -294,8 +290,8 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy request: makeRequest({ cwd: linkedCwd, providers: ["pi"] }), agentManager: { listAgents: () => [], - listImportablePersistedAgents: async () => [ - makeDescriptor({ + listImportableSessions: async () => [ + makeImportableSession({ provider: "pi", sessionId: "pi-session", nativeHandle: "pi-handle", @@ -305,7 +301,7 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy firstPrompt: "remember this", }), ], - } satisfies Pick, + } satisfies Pick, agentStorage: { list: async () => [], } satisfies Pick, @@ -321,8 +317,8 @@ test("listImportableProviderSessions rejects invalid since values", async () => request: makeRequest({ since: "not-a-date" }), agentManager: { listAgents: () => [], - listImportablePersistedAgents: async () => [], - } satisfies Pick, + listImportableSessions: async () => [], + } satisfies Pick, agentStorage: { list: async () => [], } satisfies Pick, @@ -361,7 +357,7 @@ test("normalizeImportAgentRequest accepts new and legacy import handle shapes", }); }); -test("importProviderSession resumes by provider handle, hydrates the timeline, and applies title metadata", async () => { +test("importProviderSession imports a selected provider session without listing", async () => { const cwd = "/tmp/imported-agent"; const timeline: AgentTimelineItem[] = [ { type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" }, @@ -375,22 +371,9 @@ test("importProviderSession resumes by provider handle, hydrates the timeline, a nativeHandle: "provider-thread-imported", title: null, }); - const descriptor = makeDescriptor({ - provider: "custom-codex", - sessionId: "thread-imported", - nativeHandle: "provider-thread-imported", - cwd, - title: null, - firstPrompt: "Trace recent provider sessions", - lastActivityAt: "2026-04-30T00:00:00.000Z", - }); const agentManager = { - findPersistedAgent: vi.fn().mockResolvedValue(descriptor), - resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot), - hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), + importProviderSession: vi.fn().mockResolvedValue(snapshot), getTimeline: vi.fn().mockReturnValue(timeline), - setTitle: vi.fn().mockResolvedValue(undefined), - notifyAgentState: vi.fn(), } as unknown as AgentManager; const agentStorage = { list: vi.fn().mockResolvedValue([]), @@ -411,19 +394,12 @@ test("importProviderSession resumes by provider handle, hydrates the timeline, a deps: { scheduleAgentMetadataGeneration }, }); - expect(agentManager.findPersistedAgent).toHaveBeenCalledWith( - "custom-codex", - "provider-thread-imported", - { cwd }, - ); - expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( - descriptor.persistence, - { cwd }, - undefined, - { labels: undefined }, - ); - expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id); - expect(agentManager.setTitle).toHaveBeenCalledWith(snapshot.id, "Trace recent provider sessions"); + expect(agentManager.importProviderSession).toHaveBeenCalledWith({ + provider: "custom-codex", + providerHandleId: "provider-thread-imported", + cwd, + labels: undefined, + }); expect(scheduleAgentMetadataGeneration).toHaveBeenCalledWith( expect.objectContaining({ agentManager, @@ -436,7 +412,7 @@ test("importProviderSession resumes by provider handle, hydrates the timeline, a expect(result).toEqual({ snapshot, timelineSize: 2 }); }); -test("importProviderSession builds a fallback handle when a non-OpenCode provider has no descriptor", async () => { +test("importProviderSession passes labels through the manager import operation", async () => { const cwd = "/tmp/imported-agent"; const snapshot = makeManagedAgent({ provider: "codex", @@ -445,12 +421,8 @@ test("importProviderSession builds a fallback handle when a non-OpenCode provide nativeHandle: "thread-imported", }); const agentManager = { - findPersistedAgent: vi.fn().mockResolvedValue(null), - resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot), - hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), + importProviderSession: vi.fn().mockResolvedValue(snapshot), getTimeline: vi.fn().mockReturnValue([]), - setTitle: vi.fn().mockResolvedValue(undefined), - notifyAgentState: vi.fn(), } as unknown as AgentManager; const agentStorage = { list: vi.fn().mockResolvedValue([]), @@ -463,29 +435,23 @@ test("importProviderSession builds a fallback handle when a non-OpenCode provide provider: "codex", providerHandleId: "thread-imported", cwd, + labels: { source: "import" }, }, agentManager, agentStorage, logger: { warn: vi.fn(), error: vi.fn() } as never, }); - expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( - { - provider: "codex", - sessionId: "thread-imported", - nativeHandle: "thread-imported", - metadata: { provider: "codex", cwd }, - }, - { cwd }, - undefined, - { labels: undefined }, - ); + expect(agentManager.importProviderSession).toHaveBeenCalledWith({ + provider: "codex", + providerHandleId: "thread-imported", + cwd, + labels: { source: "import" }, + }); }); -test("importProviderSession requires cwd for missing OpenCode descriptors", async () => { - const agentManager = { - findPersistedAgent: vi.fn().mockResolvedValue(null), - } as unknown as AgentManager; +test("importProviderSession requires cwd from the selected provider row", async () => { + const agentManager = {} as unknown as AgentManager; await expect( importProviderSession({ @@ -498,7 +464,5 @@ test("importProviderSession requires cwd for missing OpenCode descriptors", asyn agentStorage: { list: vi.fn() } as unknown as AgentStorage, logger: { warn: vi.fn(), error: vi.fn() } as never, }), - ).rejects.toThrow( - "OpenCode sessions require --cwd when the session cannot be found in persisted agents", - ); + ).rejects.toThrow("Import requires cwd from the selected provider session"); }); diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 7f5922a84..9f9775c43 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -1,14 +1,16 @@ import type { z } from "zod"; import type { Logger } from "pino"; import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; -import type { AgentManager, ManagedAgent } from "./agent-manager.js"; +import type { + AgentManager, + ManagedAgent, + ManagedImportableProviderSession, +} from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { AgentPersistenceHandle, AgentProvider, - AgentSessionConfig, AgentTimelineItem, - PersistedAgentDescriptor, } from "./agent-sdk-types.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import type { StructuredGenerationDaemonConfig } from "./structured-generation-providers.js"; @@ -29,7 +31,7 @@ const METADATA_GENERATION_PROMPT_PREFIX = "Generate metadata for a coding agent based on the user prompt."; export interface NormalizedImportAgentRequest { - provider: string; + provider: AgentProvider; providerHandleId: string; cwd?: string; labels?: Record; @@ -48,7 +50,7 @@ export class ImportSessionsRequestError extends Error { export interface ListImportableProviderSessionsInput { request: FetchRecentProviderSessionsRequestMessage; - agentManager: Pick; + agentManager: Pick; agentStorage: Pick; providerSnapshotManager: Pick; } @@ -91,7 +93,7 @@ export function normalizeImportAgentRequest( return { error: "Import requires providerId and providerHandleId" }; } return { - provider, + provider: provider as AgentProvider, providerHandleId, cwd: msg.cwd, labels: msg.labels, @@ -108,34 +110,31 @@ export async function listImportableProviderSessions( const providerFilter = request.providers ? new Set(request.providers) : undefined; const importedHandles = await collectImportedProviderSessionHandles(agentManager, agentStorage); - const descriptors = await agentManager.listImportablePersistedAgents({ + const sessions = await agentManager.listImportableSessions({ limit, providerFilter, cwd: request.cwd, }); let filteredAlreadyImportedCount = 0; - const candidates: PersistedAgentDescriptor[] = []; + const candidates: ManagedImportableProviderSession[] = []; const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null; - for (const descriptor of descriptors) { - if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) { + for (const session of sessions) { + if (matchesRequestCwd && !matchesRequestCwd(session.cwd)) { continue; } - if (sinceTimestamp !== null && descriptor.lastActivityAt.getTime() < sinceTimestamp) { + if (sinceTimestamp !== null && session.lastActivityAt.getTime() < sinceTimestamp) { continue; } - if (isMetadataGenerationDescriptor(descriptor)) { + if (isMetadataGenerationSession(session)) { continue; } - if (!hasUserPrompt(descriptor)) { - continue; - } - const providerHandleId = - descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId; - if (importedHandles.has(toProviderSessionHandleKey(descriptor.provider, providerHandleId))) { + if ( + importedHandles.has(toProviderSessionHandleKey(session.provider, session.providerHandleId)) + ) { filteredAlreadyImportedCount += 1; continue; } - candidates.push(descriptor); + candidates.push(session); } const entries = candidates @@ -154,32 +153,20 @@ export async function importProviderSession( input: ImportProviderSessionInput, ): Promise { const { provider, providerHandleId, cwd, labels } = input.request; - const descriptor = await input.agentManager.findPersistedAgent(provider, providerHandleId, { - cwd, - }); - if (!descriptor && provider === "opencode" && !cwd) { - throw new Error( - "OpenCode sessions require --cwd when the session cannot be found in persisted agents", - ); + if (!cwd) { + throw new Error("Import requires cwd from the selected provider session"); } - const handle = descriptor - ? applyImportCwdOverride(descriptor.persistence, cwd) - : buildImportPersistenceHandle({ provider, providerHandleId, cwd }); - const overrides = cwd ? ({ cwd } satisfies Partial) : undefined; - + const handle = buildImportPersistenceHandle({ provider, providerHandleId, cwd }); await unarchiveAgentByHandle(input.agentStorage, input.agentManager, handle); - const snapshot = await input.agentManager.resumeAgentFromPersistence( - handle, - overrides, - undefined, - { - labels, - }, - ); + const snapshot = await input.agentManager.importProviderSession({ + provider, + providerHandleId, + cwd, + labels, + }); await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id); - await input.agentManager.hydrateTimelineFromProvider(snapshot.id); - await applyImportedAgentTitle({ + scheduleImportedAgentMetadata({ snapshot, agentManager: input.agentManager, workspaceGitService: input.workspaceGitService, @@ -206,7 +193,8 @@ async function unarchiveAgentByHandle( const matched = records.find( (record) => record.persistence?.provider === handle.provider && - record.persistence?.sessionId === handle.sessionId, + (record.persistence.sessionId === handle.sessionId || + record.persistence.nativeHandle === handle.nativeHandle), ); if (!matched) { return; @@ -214,7 +202,7 @@ async function unarchiveAgentByHandle( await unarchiveAgentState(agentStorage, agentManager, matched.id); } -async function applyImportedAgentTitle(input: { +function scheduleImportedAgentMetadata(input: { snapshot: ManagedAgent; agentManager: AgentManager; workspaceGitService?: Pick; @@ -223,19 +211,16 @@ async function applyImportedAgentTitle(input: { paseoHome?: string; logger: Logger; scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration; -}): Promise { +}): void { const initialPrompt = getFirstUserMessageText(input.agentManager.getTimeline(input.snapshot.id)); if (!initialPrompt) { return; } - const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({ + const { explicitTitle } = resolveCreateAgentTitles({ configTitle: input.snapshot.config.title, initialPrompt, }); - if (!explicitTitle && provisionalTitle) { - await input.agentManager.setTitle(input.snapshot.id, provisionalTitle); - } input.scheduleAgentMetadataGeneration({ agentManager: input.agentManager, @@ -271,36 +256,17 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n } function buildImportPersistenceHandle(input: { - provider: AgentProvider; + provider: string; providerHandleId: string; - cwd?: string; + cwd: string; }): AgentPersistenceHandle { - const cwd = input.cwd ?? process.cwd(); return { provider: input.provider, sessionId: input.providerHandleId, nativeHandle: input.providerHandleId, metadata: { provider: input.provider, - cwd, - }, - }; -} - -function applyImportCwdOverride( - handle: AgentPersistenceHandle, - cwd: string | undefined, -): AgentPersistenceHandle { - if (!cwd) { - return handle; - } - - return { - ...handle, - metadata: { - ...handle.metadata, - provider: handle.provider, - cwd, + cwd: input.cwd, }, }; } @@ -339,17 +305,9 @@ function toProviderSessionHandleKey(provider: string, providerHandleId: string): return `${provider}\0${providerHandleId}`; } -function isMetadataGenerationDescriptor(descriptor: PersistedAgentDescriptor): boolean { - for (const item of descriptor.timeline) { - if (item.type !== "user_message") continue; - return item.text.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX); - } - return false; -} - -function hasUserPrompt(descriptor: PersistedAgentDescriptor): boolean { - return descriptor.timeline.some( - (item) => item.type === "user_message" && item.text.trim() !== "", +function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean { + return ( + input.firstPromptPreview?.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX) ?? false ); } diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 8e097ca28..85f3ae5bc 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -12,8 +12,6 @@ import type { AgentStreamEvent, ListModelsOptions, ListModesOptions, - ListPersistedAgentsOptions, - PersistedAgentDescriptor, ResolveAgentCreateConfigInput, ResolveAgentCreateConfigResult, } from "./agent-sdk-types.js"; @@ -277,20 +275,6 @@ function mapStreamEvent(provider: AgentProvider, event: AgentStreamEvent): Agent }; } -function mapPersistedAgentDescriptor( - provider: AgentProvider, - descriptor: PersistedAgentDescriptor, -): PersistedAgentDescriptor { - return { - ...descriptor, - provider, - persistence: { - ...descriptor.persistence, - provider, - }, - }; -} - function mapModel( provider: AgentProvider, model: AgentModelDefinition | ProviderProfileModel, @@ -401,7 +385,8 @@ function wrapClientProvider( additionalModels: ProviderProfileModel[], profileModelsAreAdditive: boolean, ): AgentClient { - const listPersistedAgents = inner.listPersistedAgents?.bind(inner); + const listImportableSessions = inner.listImportableSessions?.bind(inner); + const importSession = inner.importSession?.bind(inner); return { provider, @@ -441,11 +426,36 @@ function wrapClientProvider( listModes: inner.listModes?.bind(inner), resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), - listPersistedAgents: listPersistedAgents - ? async (options?: ListPersistedAgentsOptions) => - (await listPersistedAgents(options)).map((descriptor) => - mapPersistedAgentDescriptor(provider, descriptor), - ) + listImportableSessions: listImportableSessions + ? async (options) => await listImportableSessions(options) + : undefined, + importSession: importSession + ? async (input, context) => { + const imported = await importSession(input, { + ...context, + config: { + ...context.config, + provider: inner.provider, + }, + storedConfig: { + ...context.storedConfig, + provider: inner.provider, + }, + }); + const persistence = mapPersistenceHandle(provider, imported.persistence); + if (!persistence) { + throw new Error(`Provider '${provider}' import did not return persistence`); + } + return { + ...imported, + session: wrapSessionProvider(provider, imported.session), + config: { + ...imported.config, + provider, + }, + persistence, + }; + } : undefined, isAvailable: () => inner.isAvailable(), getDiagnostic: inner.getDiagnostic?.bind(inner), diff --git a/packages/server/src/server/agent/provider-session-import.ts b/packages/server/src/server/agent/provider-session-import.ts new file mode 100644 index 000000000..98311e6c3 --- /dev/null +++ b/packages/server/src/server/agent/provider-session-import.ts @@ -0,0 +1,77 @@ +import type { + AgentClient, + AgentPersistenceHandle, + AgentProvider, + AgentSessionConfig, + AgentStreamEvent, + ImportedProviderSession, + ImportedTimelineEntry, + ImportProviderSessionContext, + ImportProviderSessionInput, +} from "./agent-sdk-types.js"; + +export async function importSessionFromPersistence(input: { + provider: AgentProvider; + request: ImportProviderSessionInput; + context: ImportProviderSessionContext; + resumeSession: AgentClient["resumeSession"]; + config?: Partial; + persistence?: AgentPersistenceHandle; +}): Promise { + const config = { + ...input.context.config, + ...input.config, + provider: input.provider, + cwd: input.request.cwd, + } as AgentSessionConfig; + const storedConfig = { + ...input.context.storedConfig, + ...input.config, + provider: input.provider, + cwd: input.request.cwd, + } as AgentSessionConfig; + const persistence = + input.persistence ?? buildImportPersistenceHandle(input.provider, input.request, storedConfig); + const session = await input.resumeSession(persistence, config, input.context.launchContext); + const timeline = await collectImportedTimeline(session.streamHistory()); + + return { + session, + config: storedConfig, + persistence, + timeline, + }; +} + +function buildImportPersistenceHandle( + provider: AgentProvider, + input: ImportProviderSessionInput, + config: AgentSessionConfig, +): AgentPersistenceHandle { + return { + provider, + sessionId: input.providerHandleId, + nativeHandle: input.providerHandleId, + metadata: { + ...config, + provider, + cwd: input.cwd, + }, + }; +} + +async function collectImportedTimeline( + events: AsyncGenerator, +): Promise { + const timeline: ImportedTimelineEntry[] = []; + for await (const event of events) { + if (event.type !== "timeline") { + continue; + } + timeline.push({ + item: event.item, + ...(event.timestamp ? { timestamp: event.timestamp } : {}), + }); + } + return timeline; +} diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 5733abf54..da8a4783f 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -78,14 +78,17 @@ import { type AgentStreamEvent, type AgentTimelineItem, type AgentUsage, + type ImportableProviderSession, + type ImportProviderSessionContext, + type ImportProviderSessionInput, + type ListImportableSessionsOptions, type ListModesOptions, type ListModelsOptions, - type ListPersistedAgentsOptions, type McpServerConfig, - type PersistedAgentDescriptor, type ToolCallDetail, type ToolCallTimelineItem, } from "../agent-sdk-types.js"; +import { importSessionFromPersistence } from "../provider-session-import.js"; import { checkProviderLaunchAvailable, createProviderEnvSpec, @@ -742,16 +745,16 @@ export class ACPAgentClient implements AgentClient { } } - async listPersistedAgents( - options?: ListPersistedAgentsOptions, - ): Promise { + async listImportableSessions( + options?: ListImportableSessionsOptions, + ): Promise { const probe = await this.spawnProcess(PROBE_ENV); try { if (!probe.initialize.agentCapabilities?.sessionCapabilities?.list) { return []; } - const sessions: PersistedAgentDescriptor[] = []; + const sessions: ImportableProviderSession[] = []; let cursor: string | null | undefined; for (;;) { const page: ListSessionsResponse = await this.runACPRequest(() => @@ -759,22 +762,12 @@ export class ACPAgentClient implements AgentClient { ); for (const session of page.sessions) { sessions.push({ - provider: this.provider, - sessionId: session.sessionId, + providerHandleId: session.sessionId, cwd: session.cwd, title: session.title ?? null, + firstPromptPreview: null, + lastPromptPreview: null, lastActivityAt: session.updatedAt ? new Date(session.updatedAt) : new Date(0), - persistence: { - provider: this.provider, - sessionId: session.sessionId, - nativeHandle: session.sessionId, - metadata: { - provider: this.provider, - cwd: session.cwd, - title: session.title ?? null, - }, - }, - timeline: [], }); } cursor = page.nextCursor ?? null; @@ -788,6 +781,15 @@ export class ACPAgentClient implements AgentClient { } } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + return importSessionFromPersistence({ + provider: this.provider, + request: input, + context, + resumeSession: this.resumeSession.bind(this), + }); + } + async isAvailable(): Promise { try { await this.resolveLaunchCommand(); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 0f3719aee..2af3d6893 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -73,11 +73,14 @@ import { type AgentTimelineItem, type AgentUsage, type AgentRuntimeInfo, + type ImportableProviderSession, + type ImportProviderSessionContext, + type ImportProviderSessionInput, + type ListImportableSessionsOptions, type ListModelsOptions, - type ListPersistedAgentsOptions, type McpServerConfig, - type PersistedAgentDescriptor, } from "../../agent-sdk-types.js"; +import { importSessionFromPersistence } from "../../provider-session-import.js"; import { checkProviderLaunchAvailable, createProviderEnv, @@ -210,6 +213,7 @@ type ClaudeConversationRewindTarget = const CLAUDE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, + supportsSessionListing: true, supportsDynamicModes: true, supportsMcpServers: true, supportsReasoningStream: true, @@ -1351,9 +1355,9 @@ export class ClaudeAgentClient implements AgentClient { }); } - async listPersistedAgents( - options?: ListPersistedAgentsOptions, - ): Promise { + async listImportableSessions( + 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))) { @@ -1365,10 +1369,19 @@ export class ClaudeAgentClient implements AgentClient { candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)), ); return parsed - .filter((descriptor): descriptor is PersistedAgentDescriptor => descriptor !== null) + .filter((session): session is ImportableProviderSession => session !== null) .slice(0, limit); } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + return importSessionFromPersistence({ + provider: "claude", + request: input, + context, + resumeSession: this.resumeSession.bind(this), + }); + } + async isAvailable(): Promise { const launch = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, @@ -4908,7 +4921,8 @@ interface ClaudeSessionDescriptorAccumulator { sessionId: string | null; cwd: string | null; title: string | null; - timeline: AgentTimelineItem[]; + firstPromptPreview: string | null; + lastPromptPreview: string | null; } function isFinishedAccumulator(acc: ClaudeSessionDescriptorAccumulator): boolean { @@ -4941,22 +4955,18 @@ function applyClaudeSessionEntryToAccumulator( if (!acc.title) { acc.title = text; } - acc.timeline.push({ type: "user_message", text }); + const preview = normalizeImportablePromptPreview(text); + acc.firstPromptPreview ??= preview; + acc.lastPromptPreview = preview; } return; } - if (entry.type === "assistant" && entry.message) { - const text = extractClaudeUserText(entry.message); - if (text) { - acc.timeline.push({ type: "assistant_message", text }); - } - } } async function parseClaudeSessionDescriptor( filePath: string, mtime: Date, -): Promise { +): Promise { let content: string; try { content = await fsPromises.readFile(filePath, "utf8"); @@ -4968,7 +4978,8 @@ async function parseClaudeSessionDescriptor( sessionId: null, cwd: null, title: null, - timeline: [], + firstPromptPreview: null, + lastPromptPreview: null, }; for (const rawLine of content.split(/\r?\n/)) { @@ -4986,33 +4997,28 @@ async function parseClaudeSessionDescriptor( } } - const { sessionId, cwd, title, timeline } = acc; + const { sessionId, cwd, title } = acc; if (!sessionId || !cwd) { return null; } - const persistence: AgentPersistenceHandle = { - provider: "claude", - sessionId, - nativeHandle: sessionId, - metadata: { - provider: "claude", - cwd, - }, - }; - return { - provider: "claude", - sessionId, + providerHandleId: sessionId, cwd, title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`, + firstPromptPreview: acc.firstPromptPreview, + lastPromptPreview: acc.lastPromptPreview, lastActivityAt: mtime, - persistence, - timeline, }; } +function normalizeImportablePromptPreview(text: string): string | null { + const normalized = text.trim().replace(/\s+/g, " "); + if (!normalized) return null; + return normalized.length > 160 ? normalized.slice(0, 160) : normalized; +} + function extractClaudeUserText(messageRaw: unknown): string | null { const message = toObjectRecord(messageRaw); if (!message) { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts index dc77d46f1..0173328b2 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts @@ -30,7 +30,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => { } }); - test("listPersistedAgents rejects gracefully when the codex binary does not exist", async () => { + test("listImportableSessions rejects gracefully when the codex binary does not exist", async () => { const client = new CodexAppServerAgentClient(logger, { command: { mode: "replace", @@ -45,7 +45,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => { process.on("uncaughtException", onUncaught); try { - await expect(client.listPersistedAgents()).rejects.toThrow(); + await expect(client.listImportableSessions()).rejects.toThrow(); await new Promise((resolve) => setTimeout(resolve, 100)); expect(uncaughtErrors).toHaveLength(0); } finally { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index ff8e5b8a9..c809f3474 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -2671,8 +2671,8 @@ describe("Codex app-server provider", () => { }); }); -describe("Codex persisted sessions", () => { - test("listPersistedAgents uses thread list metadata without hydrating thread history", async () => { +describe("Codex importable sessions", () => { + test("listImportableSessions uses thread list metadata without hydrating thread history", async () => { const allThreads = [ { id: "thread-a1", @@ -2725,15 +2725,19 @@ describe("Codex persisted sessions", () => { return child; }; - const descriptors = await provider.listPersistedAgents({ cwd: "/workspace/project-a" }); + const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); - expect(descriptors.map((d) => d.sessionId).sort()).toEqual(["thread-a1", "thread-a2"]); - expect(descriptors.every((d) => d.cwd === "/workspace/project-a")).toBe(true); - expect(descriptors[0]).toEqual( + expect(sessions.map((session) => session.providerHandleId).sort()).toEqual([ + "thread-a1", + "thread-a2", + ]); + expect(sessions.every((session) => session.cwd === "/workspace/project-a")).toBe(true); + expect(sessions[0]).toEqual( expect.objectContaining({ - sessionId: "thread-a1", + providerHandleId: "thread-a1", title: "Named first A session", - timeline: [{ type: "user_message", text: "First A session" }], + firstPromptPreview: "First A session", + lastPromptPreview: "First A session", }), ); expect(calls).toEqual([ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 735c2c44a..4f5e270ed 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -25,10 +25,13 @@ import { type AgentTimelineItem, type ToolCallTimelineItem, type AgentUsage, + type ImportableProviderSession, + type ImportProviderSessionContext, + type ImportProviderSessionInput, + type ListImportableSessionsOptions, type ListModelsOptions, - type ListPersistedAgentsOptions, - type PersistedAgentDescriptor, } from "../agent-sdk-types.js"; +import { importSessionFromPersistence } from "../provider-session-import.js"; import type { Logger } from "pino"; import { homedir } from "node:os"; @@ -172,6 +175,7 @@ function formatOutOfBandStatusMessage(text: string): string { const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, + supportsSessionListing: true, supportsDynamicModes: false, supportsMcpServers: true, supportsReasoningStream: true, @@ -841,11 +845,6 @@ function filterCodexThreadsByCwd( return threads.filter((thread) => typeof thread.cwd === "string" && matchesCwd(thread.cwd)); } -function buildCodexThreadListTimeline(thread: Record): AgentTimelineItem[] { - const preview = typeof thread.preview === "string" ? thread.preview.trim() : ""; - return preview ? [{ type: "user_message", text: preview }] : []; -} - export function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined { const usage = toObjectRecord(tokenUsage); if (!usage) return undefined; @@ -5487,9 +5486,9 @@ export class CodexAppServerAgentClient implements AgentClient { return session; } - async listPersistedAgents( - options?: ListPersistedAgentsOptions, - ): Promise { + async listImportableSessions( + options?: ListImportableSessionsOptions, + ): Promise { const child = await this.spawnAppServer(); const client = this.deps._createCodexClient?.(child, this.logger, () => ({})) ?? @@ -5512,43 +5511,39 @@ export class CodexAppServerAgentClient implements AgentClient { ); const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : []; const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); - const descriptors: PersistedAgentDescriptor[] = threads.slice(0, limit).map((thread) => { + return threads.slice(0, limit).map((thread) => { const threadId = typeof thread.id === "string" ? thread.id : ""; const cwd = typeof thread.cwd === "string" ? thread.cwd : process.cwd(); const preview = typeof thread.preview === "string" ? thread.preview : null; const title = typeof thread.name === "string" && thread.name.trim() ? thread.name : preview; return { - provider: CODEX_PROVIDER, - sessionId: threadId, + providerHandleId: threadId, cwd, title, + firstPromptPreview: preview, + lastPromptPreview: preview, lastActivityAt: new Date( ((typeof thread.updatedAt === "number" ? thread.updatedAt : undefined) ?? (typeof thread.createdAt === "number" ? thread.createdAt : undefined) ?? 0) * 1000, ), - persistence: { - provider: CODEX_PROVIDER, - sessionId: threadId, - nativeHandle: threadId, - metadata: { - provider: CODEX_PROVIDER, - cwd, - title, - threadId, - }, - }, - timeline: buildCodexThreadListTimeline(thread), }; }); - - return descriptors; } finally { await client.dispose(); } } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + return importSessionFromPersistence({ + provider: CODEX_PROVIDER, + request: input, + context, + resumeSession: this.resumeSession.bind(this), + }); + } + async listModels(_options: ListModelsOptions): Promise { // Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored. const child = await this.spawnAppServer(); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 6f7140930..c5dd98e88 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -20,13 +20,15 @@ import type { AgentSessionConfig, AgentStreamEvent, AgentTimelineItem, + ImportableProviderSession, + ImportProviderSessionContext, + ImportProviderSessionInput, ListModesOptions, ListModelsOptions, - ListPersistedAgentsOptions, - PersistedAgentDescriptor, ToolCallDetail, ToolCallTimelineItem, } from "../agent-sdk-types.js"; +import { importSessionFromPersistence } from "../provider-session-import.js"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; export const MOCK_LOAD_TEST_PROVIDER_ID = "mock"; @@ -38,6 +40,7 @@ const MOCK_LOAD_TEST_INTERVAL_MS = 40; const CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, + supportsSessionListing: true, supportsDynamicModes: false, supportsMcpServers: false, supportsReasoningStream: true, @@ -427,12 +430,19 @@ export class MockLoadTestAgentClient implements AgentClient { return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes; } - async listPersistedAgents( - _options?: ListPersistedAgentsOptions, - ): Promise { + async listImportableSessions(): Promise { return []; } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + return importSessionFromPersistence({ + provider: MOCK_LOAD_TEST_PROVIDER_ID, + request: input, + context, + resumeSession: this.resumeSession.bind(this), + }); + } + async isAvailable(): Promise { return true; } diff --git a/packages/server/src/server/agent/providers/mock-slow-provider.ts b/packages/server/src/server/agent/providers/mock-slow-provider.ts index e2b7e56a5..7867cf6d1 100644 --- a/packages/server/src/server/agent/providers/mock-slow-provider.ts +++ b/packages/server/src/server/agent/providers/mock-slow-provider.ts @@ -10,8 +10,6 @@ import type { AgentSessionConfig, ListModelsOptions, ListModesOptions, - ListPersistedAgentsOptions, - PersistedAgentDescriptor, } from "../agent-sdk-types.js"; export const MOCK_SLOW_PROVIDER_ID = "mock-slow"; @@ -48,12 +46,6 @@ export class MockSlowProviderClient implements AgentClient { return neverResolves(); } - async listPersistedAgents( - _options?: ListPersistedAgentsOptions, - ): Promise { - return []; - } - async getDiagnostic(): Promise<{ diagnostic: string }> { return { diagnostic: 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 235f975dd..19c1428ba 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1839,7 +1839,7 @@ describe("OpenCode persisted sessions", () => { ]); }); - test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => { + test("listImportableSessions returns rows without hydrating session messages", async () => { const runtime = new TestOpenCodeRuntime(); const openCodeClient = new TestOpenCodeClient(); const cwd = "/workspace/repo"; @@ -1938,45 +1938,101 @@ describe("OpenCode persisted sessions", () => { runtime.enqueueClient(openCodeClient); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); - const descriptors = await client.listPersistedAgents({ cwd, limit: 1 }); + const sessions = await client.listImportableSessions({ cwd, limit: 1 }); - expect(descriptors).toHaveLength(1); - expect(descriptors[0]).toMatchObject({ - provider: "opencode", - sessionId: "ses_new", + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + providerHandleId: "ses_new", cwd, title: "New session", - persistence: { - provider: "opencode", - sessionId: "ses_new", - nativeHandle: "ses_new", - metadata: { - modeId: "build", - model: "opencode/big-pickle", - }, - }, + firstPromptPreview: null, + lastPromptPreview: null, }); - expect(descriptors[0]?.lastActivityAt.toISOString()).toBe("1970-01-01T00:00:03.000Z"); - expect(descriptors[0]?.timeline).toEqual([ - { type: "user_message", text: "hello world", messageId: "msg_user" }, - { type: "reasoning", text: "thinking clearly" }, - expect.objectContaining({ - type: "tool_call", - callId: "call_shell", - status: "completed", - }), - { type: "assistant_message", text: "hello back" }, - ]); + 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 }, ]); - expect(openCodeClient.calls.sessionMessages).toEqual([ - { sessionID: "ses_new", directory: cwd }, + expect(openCodeClient.calls.sessionMessages).toEqual([]); + }); + + test("importSession reads only the selected OpenCode session without listing", async () => { + const runtime = new TestOpenCodeRuntime(); + const metadataClient = new TestOpenCodeClient(); + const resumedClient = new TestOpenCodeClient(); + const cwd = "/workspace/repo"; + const selectedSession = { + id: "ses_selected", + directory: cwd, + title: "Selected session", + time: { created: 2000, updated: 3000 }, + }; + const messages = [ + { + info: { + id: "msg_user", + sessionID: "ses_selected", + role: "user", + time: { created: 2100 }, + agent: "build", + model: { providerID: "opencode", modelID: "big-pickle" }, + }, + parts: [ + { + id: "prt_user", + sessionID: "ses_selected", + messageID: "msg_user", + type: "text", + text: "import only this session", + time: { start: 2100 }, + }, + ], + }, + ]; + metadataClient.sessionGetResponse = { data: selectedSession }; + metadataClient.sessionMessagesResponse = { data: messages }; + resumedClient.sessionGetResponse = { data: selectedSession }; + resumedClient.sessionMessagesResponse = { data: messages }; + runtime.enqueueClient(metadataClient); + runtime.enqueueClient(resumedClient); + + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); + const imported = await client.importSession( + { providerHandleId: "ses_selected", cwd }, + { + config: { provider: "opencode", cwd }, + storedConfig: { provider: "opencode", cwd }, + }, + ); + + expect(metadataClient.calls.experimentalSessionList).toEqual([]); + expect(metadataClient.calls.sessionGet).toEqual([ + { sessionID: "ses_selected", directory: cwd }, + ]); + expect(metadataClient.calls.sessionMessages).toEqual([ + { sessionID: "ses_selected", directory: cwd }, + ]); + expect(imported.config).toMatchObject({ + provider: "opencode", + cwd, + title: "Selected session", + modeId: "build", + model: "opencode/big-pickle", + }); + expect(imported.persistence).toMatchObject({ + provider: "opencode", + sessionId: "ses_selected", + nativeHandle: "ses_selected", + }); + expect(imported.timeline.map((entry) => entry.item)).toEqual([ + { type: "user_message", text: "import only this session", messageId: "msg_user" }, + ]); + expect(resumedClient.calls.sessionMessages).toEqual([ + { sessionID: "ses_selected", directory: cwd }, ]); }); - test("listPersistedAgents matches Windows cwd paths with forward slashes", async () => { + test("listImportableSessions matches Windows cwd paths with forward slashes", async () => { const runtime = new TestOpenCodeRuntime(); const openCodeClient = new TestOpenCodeClient(); const requestedCwd = "C:/Users/Administrator/GhostFactory"; @@ -2001,12 +2057,11 @@ describe("OpenCode persisted sessions", () => { runtime.enqueueClient(openCodeClient); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); - const descriptors = await client.listPersistedAgents({ cwd: requestedCwd, limit: 1 }); + const sessions = await client.listImportableSessions({ cwd: requestedCwd, limit: 1 }); - expect(descriptors).toHaveLength(1); - expect(descriptors[0]).toMatchObject({ - provider: "opencode", - sessionId: "ses_windows", + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + providerHandleId: "ses_windows", cwd: storedCwd, title: "Windows session", }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 64015685e..2e7d0db22 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -38,16 +38,19 @@ import { type AgentStreamEvent, type AgentTimelineItem, type AgentUsage, + type ImportableProviderSession, + type ImportProviderSessionContext, + type ImportProviderSessionInput, + type ListImportableSessionsOptions, type ResolveAgentCreateConfigInput, type ResolveAgentCreateConfigResult, type ListModelsOptions, type ListModesOptions, - type ListPersistedAgentsOptions, type McpServerConfig, - type PersistedAgentDescriptor, type ToolCallDetail, type ToolCallTimelineItem, } from "../agent-sdk-types.js"; +import { importSessionFromPersistence } from "../provider-session-import.js"; import { isDefaultAgentCreateConfigUnattended, resolveDefaultAgentCreateConfig, @@ -84,6 +87,7 @@ import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js"; const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, + supportsSessionListing: true, supportsDynamicModes: true, supportsMcpServers: true, supportsReasoningStream: true, @@ -907,10 +911,10 @@ function buildOpenCodeUserTimelineText(prompt: AgentPromptInput): string { .join("\n"); } -async function collectOpenCodePersistedAgentsFromSdk( - client: Pick, - options?: ListPersistedAgentsOptions, -): Promise { +async function collectOpenCodeImportableSessionsFromSdk( + client: Pick, + options?: ListImportableSessionsOptions, +): Promise { const limit = options?.limit ?? OPENCODE_PERSISTED_SESSION_LIMIT; const sessionListLimit = options?.cwd ? Math.max(limit, OPENCODE_PERSISTED_SESSION_LIMIT) : limit; const response = await client.experimental.session.list({ @@ -923,46 +927,19 @@ async function collectOpenCodePersistedAgentsFromSdk( throw new Error(`Failed to list OpenCode sessions: ${JSON.stringify(response.error)}`); } - const sessions = response.data ?? []; const matchesCwd = options?.cwd ? createPathEquivalenceMatcher(options.cwd) : null; - const candidates = sessions + return (response.data ?? []) .filter((session) => !matchesCwd || matchesCwd(session.directory)) .sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left)) - .slice(0, limit); - - return await Promise.all( - candidates.map((session) => buildOpenCodePersistedAgentDescriptor(client, session)), - ); -} - -async function buildOpenCodePersistedAgentDescriptor( - client: Pick, - session: OpenCodePersistedSession, -): Promise { - const messages = await readOpenCodeSessionMessagesFromSdk(client, session); - const timeline = buildOpenCodeSessionTimeline(messages); - const modeId = resolveOpenCodePersistedSessionModeId(session, messages); - const model = resolveOpenCodePersistedSessionModel(session, messages); - return { - provider: "opencode", - sessionId: session.id, - cwd: session.directory, - title: normalizeOpenCodeSessionTitle(session.title), - lastActivityAt: new Date(getOpenCodeSessionTimestamp(session)), - persistence: { - provider: "opencode", - sessionId: session.id, - nativeHandle: session.id, - metadata: { - provider: "opencode", - cwd: session.directory, - title: normalizeOpenCodeSessionTitle(session.title), - ...(modeId ? { modeId } : {}), - ...(model ? { model } : {}), - }, - }, - timeline, - }; + .slice(0, limit) + .map((session) => ({ + providerHandleId: session.id, + cwd: session.directory, + title: normalizeOpenCodeSessionTitle(session.title), + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: new Date(getOpenCodeSessionTimestamp(session)), + })); } function normalizeOpenCodeSessionTitle(title: string | null | undefined): string | null { @@ -1498,9 +1475,9 @@ export class OpenCodeAgentClient implements AgentClient { return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))]; } - async listPersistedAgents( - options?: ListPersistedAgentsOptions, - ): Promise { + async listImportableSessions( + options?: ListImportableSessionsOptions, + ): Promise { const acquisition = await this.runtime.acquireServer({ force: false }); const { url } = acquisition.server; const client = this.runtime.createClient({ @@ -1509,7 +1486,43 @@ export class OpenCodeAgentClient implements AgentClient { }); try { - return await collectOpenCodePersistedAgentsFromSdk(client, options); + return await collectOpenCodeImportableSessionsFromSdk(client, options); + } finally { + acquisition.release(); + } + } + + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + const acquisition = await this.runtime.acquireServer({ force: false }); + const { url } = acquisition.server; + const client = this.runtime.createClient({ + baseUrl: url, + directory: input.cwd, + }); + + try { + const sessionResponse = await client.session.get({ + sessionID: input.providerHandleId, + directory: input.cwd, + }); + if (sessionResponse.error || !sessionResponse.data) { + throw new Error(`Failed to load OpenCode session ${input.providerHandleId}`); + } + const session = sessionResponse.data; + const messages = await readOpenCodeSessionMessagesFromSdk(client, session); + const modeId = resolveOpenCodePersistedSessionModeId(session, messages); + const model = resolveOpenCodePersistedSessionModel(session, messages); + return await importSessionFromPersistence({ + provider: "opencode", + request: input, + context, + resumeSession: this.resumeSession.bind(this), + config: { + title: normalizeOpenCodeSessionTitle(session.title) ?? undefined, + ...(modeId ? { modeId } : {}), + ...(model ? { model } : {}), + }, + }); } finally { acquisition.release(); } diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts index e9d1b6234..e53f7ded5 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts @@ -66,6 +66,7 @@ export class TestOpenCodeClient { sessionCommand: [] as unknown[], sessionCreate: [] as unknown[], sessionDelete: [] as unknown[], + sessionGet: [] as unknown[], sessionMessages: [] as unknown[], sessionPromptAsync: [] as unknown[], sessionSummarize: [] as unknown[], @@ -89,6 +90,9 @@ export class TestOpenCodeClient { sessionCommandResponse: OpenCodeResponse = {}; sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } }; sessionDeleteResponse: OpenCodeResponse = {}; + sessionGetResponse: OpenCodeResponse = { + data: { id: "session-1", directory: "/workspace/repo", title: null }, + }; sessionMessagesResponse: OpenCodeResponse = { data: [] }; sessionPromptAsyncEvents: unknown[] = [idleEvent()]; sessionPromptAsyncResponse: OpenCodeResponse = {}; @@ -196,6 +200,10 @@ export class TestOpenCodeClient { this.calls.sessionDelete.push(parameters); return this.sessionDeleteResponse; }, + get: async (parameters: unknown) => { + this.calls.sessionGet.push(parameters); + return this.sessionGetResponse; + }, messages: async (parameters: unknown) => { this.calls.sessionMessages.push(parameters); return this.sessionMessagesResponse; diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 8274a0eb0..a27cdf22b 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -720,23 +720,14 @@ describe("PiRpcAgentClient", () => { providerParams: { sessionDir: sessionsDir }, }); - await expect(client.listPersistedAgents({ cwd })).resolves.toEqual([ + await expect(client.listImportableSessions({ cwd })).resolves.toEqual([ { - provider: "pi", - sessionId: "pi-session-jsonl", + providerHandleId: sessionFile, cwd, title: "Imported Pi session", + firstPromptPreview: "first prompt", + lastPromptPreview: "last prompt", lastActivityAt: new Date("2026-01-01T00:00:03.000Z"), - persistence: { - provider: "pi", - sessionId: "pi-session-jsonl", - nativeHandle: sessionFile, - metadata: { provider: "pi", cwd }, - }, - timeline: [ - { type: "user_message", text: "first prompt" }, - { type: "user_message", text: "last prompt" }, - ], }, ]); }); @@ -777,18 +768,13 @@ describe("PiRpcAgentClient", () => { }, }); - await expect(client.listPersistedAgents({ cwd })).resolves.toMatchObject([ + await expect(client.listImportableSessions({ cwd })).resolves.toMatchObject([ { - provider: "pi", - sessionId: "pi-default-session", + providerHandleId: sessionFile, cwd, title: "default dir prompt", - persistence: { - provider: "pi", - sessionId: "pi-default-session", - nativeHandle: sessionFile, - metadata: { provider: "pi", cwd }, - }, + firstPromptPreview: "default dir prompt", + lastPromptPreview: "default dir prompt", }, ]); }); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 117cae2c8..0be7988ca 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -26,11 +26,14 @@ import { type AgentSlashCommandKind, type AgentStreamEvent, type AgentUsage, - type ListPersistedAgentsOptions, + type ImportableProviderSession, + type ImportProviderSessionContext, + type ImportProviderSessionInput, + type ListImportableSessionsOptions, type ListModesOptions, type ListModelsOptions, - type PersistedAgentDescriptor, } from "../../agent-sdk-types.js"; +import { importSessionFromPersistence } from "../../provider-session-import.js"; import { runProviderTurn } from "../provider-runner.js"; import { checkProviderLaunchAvailable, @@ -54,7 +57,7 @@ import { } from "./history-mapper.js"; import { PiCliRuntime } from "./cli-runtime.js"; import { revertPiConversation } from "./rewind.js"; -import { listPiPersistedAgents } from "./session-descriptor.js"; +import { listPiImportableSessions } from "./session-descriptor.js"; import type { PiRuntime, PiRuntimeSession } from "./runtime.js"; import type { PiAgentSessionEvent, @@ -122,6 +125,7 @@ function mapPiCommandKind(source: PiRpcSlashCommand["source"]): AgentSlashComman const PI_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, + supportsSessionListing: true, supportsDynamicModes: true, supportsMcpServers: false, supportsReasoningStream: true, @@ -1969,17 +1973,25 @@ export class PiRpcAgentClient implements AgentClient { return []; } - async listPersistedAgents( - options?: ListPersistedAgentsOptions, - ): Promise { - return await listPiPersistedAgents({ + async listImportableSessions( + options?: ListImportableSessionsOptions, + ): Promise { + return await listPiImportableSessions({ ...options, - provider: PI_PROVIDER, sessionDir: this.providerParams.sessionDir, runtimeSettings: this.runtimeSettings, }); } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + return importSessionFromPersistence({ + provider: PI_PROVIDER, + request: input, + context, + resumeSession: this.resumeSession.bind(this), + }); + } + async isAvailable(): Promise { const launch = await this.resolvePiLaunch(); const availability = await checkProviderLaunchAvailable(launch); 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 38b09ed7e..deafe54f9 100644 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.ts +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.ts @@ -3,15 +3,12 @@ import { homedir } from "node:os"; import path from "node:path"; import type { - AgentPersistenceHandle, - AgentTimelineItem, - ListPersistedAgentsOptions, - PersistedAgentDescriptor, + ImportableProviderSession, + ListImportableSessionsOptions, } from "../../agent-sdk-types.js"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; import { createRealpathAwarePathMatcher } from "../../../../utils/path.js"; -const PI_PROVIDER = "pi"; const PI_CONFIG_DIR_NAME = ".pi"; const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"; const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR"; @@ -19,8 +16,7 @@ const HEAD_BYTES = 64 * 1024; const TAIL_BYTES = 256 * 1024; const FULL_SCAN_LINE_LIMIT = 2_000; -interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions { - provider?: string; +interface PiSessionDescriptorOptions extends ListImportableSessionsOptions { sessionDir?: string; runtimeSettings?: ProviderRuntimeSettings; env?: NodeJS.ProcessEnv; @@ -39,24 +35,23 @@ interface PiSessionTail { lastUserMessage: string | null; } -export async function listPiPersistedAgents( +export async function listPiImportableSessions( options: PiSessionDescriptorOptions = {}, -): Promise { - const provider = options.provider ?? PI_PROVIDER; +): Promise { const sessionsDir = await resolvePiSessionsDir(options); const files = await walkJsonlFiles(sessionsDir); const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null; const limit = options.limit ?? 20; - const descriptors: PersistedAgentDescriptor[] = []; + const sessions: ImportableProviderSession[] = []; for (const file of files) { - const descriptor = await readPiSessionDescriptor(file, provider); - if (!descriptor) continue; - if (matchesCwd && !matchesCwd(descriptor.cwd)) continue; - descriptors.push(descriptor); + const session = await readPiImportableSession(file); + if (!session) continue; + if (matchesCwd && !matchesCwd(session.cwd)) continue; + sessions.push(session); } - return descriptors + return sessions .sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()) .slice(0, limit); } @@ -157,10 +152,9 @@ async function walkJsonlFiles(root: string): Promise { return files.flat(); } -async function readPiSessionDescriptor( +async function readPiImportableSession( filePath: string, - provider: string, -): Promise { +): Promise { const firstLine = await readFirstLine(filePath); if (!firstLine) return null; const header = parseSessionHeader(firstLine); @@ -172,29 +166,15 @@ async function readPiSessionDescriptor( const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage; const lastActivityAt = tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0); - const timeline = buildPreviewTimeline({ - firstUserMessage: headInfo.firstUserMessage, - lastUserMessage: tailInfo.lastUserMessage, - }); - - const persistence: AgentPersistenceHandle = { - provider, - sessionId: header.sessionId, - nativeHandle: filePath, - metadata: { - provider, - cwd: header.cwd, - }, - }; - return { - provider, - sessionId: header.sessionId, + providerHandleId: filePath, cwd: header.cwd, title, + firstPromptPreview: normalizePromptPreview(headInfo.firstUserMessage), + lastPromptPreview: normalizePromptPreview( + tailInfo.lastUserMessage ?? headInfo.firstUserMessage, + ), lastActivityAt, - persistence, - timeline, }; } @@ -326,20 +306,6 @@ async function scanSessionHead(filePath: string): Promise<{ return { title, firstUserMessage }; } -function buildPreviewTimeline(input: { - firstUserMessage: string | null; - lastUserMessage: string | null; -}): AgentTimelineItem[] { - const items: AgentTimelineItem[] = []; - if (input.firstUserMessage) { - items.push({ type: "user_message", text: input.firstUserMessage }); - } - if (input.lastUserMessage && input.lastUserMessage !== input.firstUserMessage) { - items.push({ type: "user_message", text: input.lastUserMessage }); - } - return items; -} - function parseJsonRecord(line: string): Record | null { if (!line) return null; try { @@ -358,6 +324,12 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +function normalizePromptPreview(text: string | null): string | null { + const normalized = text?.trim().replace(/\s+/g, " ") ?? ""; + if (!normalized) return null; + return normalized.length > 160 ? normalized.slice(0, 160) : normalized; +} + function parseDate(value: unknown): Date | null { if (typeof value !== "string" && typeof value !== "number") { return null; diff --git a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts index d4a196a97..ac4df109e 100644 --- a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import pino from "pino"; import { beforeAll, beforeEach, describe, expect, test } from "vitest"; -import type { AgentClient, PersistedAgentDescriptor } from "../agent/agent-sdk-types.js"; +import type { AgentClient, ImportableProviderSession } from "../agent/agent-sdk-types.js"; import { OpenCodeServerManager } from "../agent/providers/opencode/server-manager.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -41,11 +41,16 @@ async function withConnectedOpenCodeDaemon( async function deletePersistedSessions( provider: AgentClient, - sessions: ReadonlyArray, + sessions: ReadonlyArray, ): Promise { await Promise.all( sessions.map(async (session) => { - const resumed = await provider.resumeSession(session.persistence); + const resumed = await provider.resumeSession({ + provider: provider.provider, + sessionId: session.providerHandleId, + nativeHandle: session.providerHandleId, + metadata: { provider: provider.provider, cwd: session.cwd }, + }); await resumed.close(); }), ); @@ -68,10 +73,10 @@ describe("daemon E2E (real opencode) - draft feature discovery", () => { const logger = pino({ level: "silent" }); const provider = createRealProviderClient("opencode", logger); const cwd = tmpCwd(); - let after: PersistedAgentDescriptor[] = []; + let after: ImportableProviderSession[] = []; try { - expect(await provider.listPersistedAgents?.({ cwd })).toEqual([]); + expect(await provider.listImportableSessions?.({ cwd })).toEqual([]); await withConnectedOpenCodeDaemon(provider, async ({ client }) => { const response = await client.listProviderFeatures({ @@ -84,7 +89,7 @@ describe("daemon E2E (real opencode) - draft feature discovery", () => { expect(response.features ?? []).toEqual([]); }); - after = (await provider.listPersistedAgents?.({ cwd })) ?? []; + after = (await provider.listImportableSessions?.({ cwd })) ?? []; } finally { await deletePersistedSessions(provider, after); rmSync(cwd, { recursive: true, force: true }); @@ -98,10 +103,10 @@ describe("daemon E2E (real opencode) - draft feature discovery", () => { const logger = pino({ level: "silent" }); const provider = createRealProviderClient("opencode", logger); const cwd = tmpCwd(); - let after: PersistedAgentDescriptor[] = []; + let after: ImportableProviderSession[] = []; try { - expect(await provider.listPersistedAgents?.({ cwd })).toEqual([]); + expect(await provider.listImportableSessions?.({ cwd })).toEqual([]); await withConnectedOpenCodeDaemon(provider, async ({ client }) => { const response = await client.listCommands("draft-opencode-agent", { @@ -115,7 +120,7 @@ describe("daemon E2E (real opencode) - draft feature discovery", () => { expect(response.commands.length).toBeGreaterThan(0); }); - after = (await provider.listPersistedAgents?.({ cwd })) ?? []; + after = (await provider.listImportableSessions?.({ cwd })) ?? []; } finally { await deletePersistedSessions(provider, after); rmSync(cwd, { recursive: true, force: true }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 42d10f22a..acafe7471 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3305,7 +3305,6 @@ export class Session { logger: this.sessionLogger, }); await this.registerWorkspaceForImportedAgent(snapshot.cwd); - await this.forwardAgentUpdate(snapshot); const agentPayload = await this.buildAgentPayload(snapshot); this.emit({ type: "status", diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 0abe620e9..b5cd4f560 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -17,7 +17,6 @@ import type { AgentSession, AgentSessionConfig, AgentStreamEvent, - PersistedAgentDescriptor, } from "./agent/agent-sdk-types.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; @@ -63,12 +62,8 @@ interface SessionTestAccess { }; agentManager: { listAgents(): unknown[]; - listImportablePersistedAgents(options?: unknown): Promise; - findPersistedAgent( - provider: string, - providerHandleId: string, - options?: unknown, - ): Promise; + listImportableSessions(options?: unknown): Promise; + importProviderSession(input: unknown): Promise; resumeAgentFromPersistence( handle: unknown, overrides?: unknown, @@ -274,7 +269,7 @@ function makeManagedAgent(input: { }; } -function makePersistedProviderSession(input: { +function makeImportableProviderSession(input: { provider: string; sessionId: string; nativeHandle?: string; @@ -282,19 +277,23 @@ function makePersistedProviderSession(input: { title?: string | null; lastActivityAt: string; firstPrompt?: string; -}): PersistedAgentDescriptor { +}): { + provider: string; + providerHandleId: string; + cwd: string; + title: string | null; + firstPromptPreview: string | null; + lastPromptPreview: string | null; + lastActivityAt: Date; +} { return { provider: input.provider, - sessionId: input.sessionId, + providerHandleId: input.nativeHandle ?? input.sessionId, cwd: input.cwd, title: input.title ?? null, + firstPromptPreview: input.firstPrompt ?? null, + lastPromptPreview: input.firstPrompt ?? null, lastActivityAt: new Date(input.lastActivityAt), - persistence: { - provider: input.provider, - sessionId: input.sessionId, - ...(input.nativeHandle ? { nativeHandle: input.nativeHandle } : {}), - }, - timeline: input.firstPrompt ? [{ type: "user_message", text: input.firstPrompt }] : [], }; } @@ -2127,8 +2126,8 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions }, }, ]; - const persistedDescriptors: PersistedAgentDescriptor[] = [ - makePersistedProviderSession({ + const importableSessions = [ + makeImportableProviderSession({ provider: "codex", sessionId: "outside-filter", nativeHandle: "outside-filter-handle", @@ -2136,7 +2135,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions title: "Outside filter", lastActivityAt: "2026-04-30T12:05:00.000Z", }), - makePersistedProviderSession({ + makeImportableProviderSession({ provider: "codex", sessionId: "stored-session", nativeHandle: "stored-handle", @@ -2145,14 +2144,14 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions lastActivityAt: "2026-04-30T12:04:00.000Z", firstPrompt: "stored prompt", }), - makePersistedProviderSession({ + makeImportableProviderSession({ provider: "claude", sessionId: "wrong-provider", cwd: "/tmp/recent", title: "Wrong provider", lastActivityAt: "2026-04-30T12:03:00.000Z", }), - makePersistedProviderSession({ + makeImportableProviderSession({ provider: "codex", sessionId: "older-session", nativeHandle: "older-handle", @@ -2160,7 +2159,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions title: "Older than since", lastActivityAt: "2026-04-29T23:59:59.000Z", }), - makePersistedProviderSession({ + makeImportableProviderSession({ provider: "codex", sessionId: "newer-session", nativeHandle: "newer-handle", @@ -2169,7 +2168,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions lastActivityAt: "2026-04-30T12:02:00.000Z", firstPrompt: "newer prompt", }), - makePersistedProviderSession({ + makeImportableProviderSession({ provider: "codex", sessionId: "second-session", nativeHandle: "second-handle", @@ -2178,7 +2177,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions lastActivityAt: "2026-04-30T12:00:00.000Z", firstPrompt: "second prompt", }), - makePersistedProviderSession({ + makeImportableProviderSession({ provider: "codex", sessionId: "third-session", nativeHandle: "third-handle", @@ -2187,7 +2186,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions lastActivityAt: "2026-04-30T11:59:00.000Z", firstPrompt: "third prompt", }), - makePersistedProviderSession({ + makeImportableProviderSession({ provider: "codex", sessionId: "live-session", nativeHandle: "live-handle", @@ -2199,13 +2198,12 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions ]; // The real AgentManager filters by providerFilter at the fan-out level // (Phase 1). Mirror that here so the mock matches the contract. - session.agentManager.listImportablePersistedAgents = async (options?: unknown) => { + session.agentManager.listImportableSessions = async (options?: unknown) => { const providerFilter = (options as { providerFilter?: Set } | undefined) ?.providerFilter; - if (!providerFilter) { - return persistedDescriptors; - } - return persistedDescriptors.filter((d) => providerFilter.has(d.provider)); + return providerFilter + ? importableSessions.filter((entry) => providerFilter.has(entry.provider)) + : importableSessions; }; session.agentStorage.list = async () => [ { @@ -2275,7 +2273,7 @@ test("fetch_recent_provider_sessions_request forwards providerFilter to agent ma session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); session.agentManager.listAgents = () => []; session.agentStorage.list = async () => []; - session.agentManager.listImportablePersistedAgents = async (options?: unknown) => { + session.agentManager.listImportableSessions = async (options?: unknown) => { capturedOptions = options as { providerFilter?: Set; limit?: number }; return []; }; @@ -2316,16 +2314,16 @@ test("fetch_recent_provider_sessions_request reports filteredAlreadyImportedCoun }, ]; session.agentStorage.list = async () => []; - session.agentManager.listImportablePersistedAgents = async () => [ - makePersistedProviderSession({ + session.agentManager.listImportableSessions = async () => [ + { provider: "codex", - sessionId: "live-session", - nativeHandle: "live-handle", + providerHandleId: "live-handle", cwd: "/tmp/recent", title: "Already live", - lastActivityAt: "2026-04-30T12:01:00.000Z", - firstPrompt: "live prompt", - }), + firstPromptPreview: "live prompt", + lastPromptPreview: "live prompt", + lastActivityAt: new Date("2026-04-30T12:01:00.000Z"), + }, ]; await session.handleMessage({ @@ -2951,9 +2949,7 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () updatedAt: "2026-05-21T00:00:00.000Z", }); session.agentManager.listAgents = () => [managed]; - session.agentManager.findPersistedAgent = async () => null; - session.agentManager.resumeAgentFromPersistence = async () => managed; - session.agentManager.hydrateTimelineFromProvider = async () => undefined; + session.agentManager.importProviderSession = async () => managed; session.agentManager.getTimeline = () => []; session.agentManager.setTitle = async () => undefined; session.agentStorage.list = async () => [];