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.
This commit is contained in:
Mohamed Boudra
2026-06-10 11:25:16 +07:00
parent cfd72b815c
commit 51d1d007ce
28 changed files with 1035 additions and 744 deletions

View File

@@ -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<boolean>;
// Optional:
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
listImportableSessions?(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
importSession?(
input: ImportProviderSessionInput,
context: ImportProviderSessionContext,
): Promise<ImportedProviderSession>;
getDiagnostic?(): Promise<{ diagnostic: string }>;
}
```

View File

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

View File

@@ -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<string>;
importErrored: boolean;
@@ -98,6 +99,7 @@ function SheetStatusMessages({
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
hasRows,
allQueriesErrored,
erroredProviderLabels,
importErrored,
@@ -114,7 +116,7 @@ function SheetStatusMessages({
{hasNoImportableProviders ? (
<Text style={styles.statusText}>No importable providers are enabled.</Text>
) : null}
{isLoadingSessions ? (
{isLoadingSessions && !hasRows ? (
<View style={styles.statusRow}>
<LoadingSpinner color={theme.colors.foregroundMuted} />
<Text style={styles.statusText}>Loading recent sessions...</Text>
@@ -176,25 +178,6 @@ function SheetEmptyState({ title }: { title: string }) {
);
}
function buildProviderFilterOptions(
providers: ReadonlyArray<string>,
providerLabelById: ReadonlyMap<string, string>,
): SegmentedControlOption<string>[] {
const options: SegmentedControlOption<string>[] = [
{ 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 }) => <ProviderIcon color={color} size={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<string>(ALL_FILTER_VALUE);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(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<ComboboxOption[]>(
() => [
{ 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<string, React.ReactNode>();
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
for (const provider of filterProviders) {
const ProviderIcon = getProviderIcon(provider);
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
}
return map;
}, [filterProviders, theme.colors.foregroundMuted]);
const renderFilterOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={filterOptionIcons.get(option.id)}
/>
),
[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 ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterRow}
>
<SegmentedControl
testID="import-session-filters"
size="sm"
options={filterOptions}
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
(() => {
const ProviderIcon = getProviderIcon(selectedProvider);
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
})()
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
value={selectedProvider}
onValueChange={setSelectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
/>
</ScrollView>
</View>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 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],
},

View File

@@ -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<T>(): Deferred<T> {
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<PersistedAgentDescriptor[]> {
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");

View File

@@ -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<string>;
};
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<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>();
@@ -612,34 +662,37 @@ export class AgentManager {
.map((agent) => Object.assign({}, agent));
}
async listImportablePersistedAgents(
async listImportableSessions(
options?: ImportablePersistedAgentQueryOptions,
): Promise<PersistedAgentDescriptor[]> {
): Promise<ManagedImportableProviderSession[]> {
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<ListPersistedAgentsOptions, "cwd">,
): Promise<PersistedAgentDescriptor | null> {
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<ProviderAvailability[]> {
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<string, string>;
}): Promise<ManagedAgent> {
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<ManagedAgent> {
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<ForegroundTurnWaiter>(),
finalizedForegroundTurnIds: new Set<string>(),
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<void> {
private async refreshSessionState(
agent: ActiveManagedAgent,
options?: { emit?: boolean },
): Promise<void> {
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<void> {
private async refreshRuntimeInfo(
agent: ActiveManagedAgent,
options?: { emit?: boolean },
): Promise<void> {
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 {

View File

@@ -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<Partial<ManagedAgent>, "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,
});

View File

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

View File

@@ -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<AgentSlashCommand[]>;
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
listImportableSessions?(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
importSession?(
input: ImportProviderSessionInput,
context: ImportProviderSessionContext,
): Promise<ImportedProviderSession>;
/**
* Check if this provider is available (CLI binary is installed).
* Returns true if available, false otherwise.

View File

@@ -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<AgentManager, "listAgents" | "listImportablePersistedAgents">;
listImportableSessions,
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">;
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<AgentManager, "listAgents" | "listImportablePersistedAgents">,
listImportableSessions: async () => sessions,
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
@@ -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<AgentManager, "listAgents" | "listImportablePersistedAgents">,
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
@@ -321,8 +317,8 @@ test("listImportableProviderSessions rejects invalid since values", async () =>
request: makeRequest({ since: "not-a-date" }),
agentManager: {
listAgents: () => [],
listImportablePersistedAgents: async () => [],
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
listImportableSessions: async () => [],
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
@@ -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");
});

View File

@@ -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<string, string>;
@@ -48,7 +50,7 @@ export class ImportSessionsRequestError extends Error {
export interface ListImportableProviderSessionsInput {
request: FetchRecentProviderSessionsRequestMessage;
agentManager: Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">;
agentManager: Pick<AgentManager, "listAgents" | "listImportableSessions">;
agentStorage: Pick<AgentStorage, "list">;
providerSnapshotManager: Pick<ProviderSnapshotManager, "getProviderLabel">;
}
@@ -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<ImportProviderSessionResult> {
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<AgentSessionConfig>) : 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<WorkspaceGitService, "resolveRepoRoot">;
@@ -223,19 +211,16 @@ async function applyImportedAgentTitle(input: {
paseoHome?: string;
logger: Logger;
scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration;
}): Promise<void> {
}): 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
);
}

View File

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

View File

@@ -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<AgentSessionConfig>;
persistence?: AgentPersistenceHandle;
}): Promise<ImportedProviderSession> {
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<AgentStreamEvent>,
): Promise<ImportedTimelineEntry[]> {
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;
}

View File

@@ -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<PersistedAgentDescriptor[]> {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
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<boolean> {
try {
await this.resolveLaunchCommand();

View File

@@ -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<PersistedAgentDescriptor[]> {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const projectsRoot = path.join(configDir, "projects");
if (!(await pathExists(projectsRoot))) {
@@ -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<boolean> {
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<PersistedAgentDescriptor | null> {
): Promise<ImportableProviderSession | null> {
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) {

View File

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

View File

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

View File

@@ -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<string, unknown>): 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<PersistedAgentDescriptor[]> {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
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<AgentModelDefinition[]> {
// Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored.
const child = await this.spawnAppServer();

View File

@@ -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<PersistedAgentDescriptor[]> {
async listImportableSessions(): Promise<ImportableProviderSession[]> {
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<boolean> {
return true;
}

View File

@@ -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<AgentMode[]>();
}
async listPersistedAgents(
_options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
return [];
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
return {
diagnostic:

View File

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

View File

@@ -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<OpencodeClient, "experimental" | "session">,
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
async function collectOpenCodeImportableSessionsFromSdk(
client: Pick<OpencodeClient, "experimental">,
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
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<OpencodeClient, "session">,
session: OpenCodePersistedSession,
): Promise<PersistedAgentDescriptor> {
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<PersistedAgentDescriptor[]> {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
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();
}

View File

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

View File

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

View File

@@ -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<PersistedAgentDescriptor[]> {
return await listPiPersistedAgents({
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
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<boolean> {
const launch = await this.resolvePiLaunch();
const availability = await checkProviderLaunchAvailable(launch);

View File

@@ -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<PersistedAgentDescriptor[]> {
const provider = options.provider ?? PI_PROVIDER;
): Promise<ImportableProviderSession[]> {
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<string[]> {
return files.flat();
}
async function readPiSessionDescriptor(
async function readPiImportableSession(
filePath: string,
provider: string,
): Promise<PersistedAgentDescriptor | null> {
): Promise<ImportableProviderSession | null> {
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<string, unknown> | 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;

View File

@@ -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<PersistedAgentDescriptor>,
sessions: ReadonlyArray<ImportableProviderSession>,
): Promise<void> {
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 });

View File

@@ -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",

View File

@@ -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<PersistedAgentDescriptor[]>;
findPersistedAgent(
provider: string,
providerHandleId: string,
options?: unknown,
): Promise<PersistedAgentDescriptor | null>;
listImportableSessions(options?: unknown): Promise<unknown[]>;
importProviderSession(input: unknown): Promise<unknown>;
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<string> } | 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<string>; 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 () => [];