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:29:27 +07:00
parent cfd72b815c
commit 51d1d007ce
28 changed files with 1035 additions and 744 deletions
+9 -1
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. 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 ## Provider Snapshot Refresh Contract
@@ -315,7 +317,13 @@ interface AgentClient {
isAvailable(): Promise<boolean>; isAvailable(): Promise<boolean>;
// Optional: // Optional:
listModes?(options: ListModesOptions): Promise<AgentMode[]>; 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 }>; getDiagnostic?(): Promise<{ diagnostic: string }>;
} }
``` ```
@@ -71,7 +71,9 @@ vi.mock("lucide-react-native", () => {
return Icon; return Icon;
}; };
return { return {
ChevronDown: icon("ChevronDown"),
Inbox: icon("Inbox"), Inbox: icon("Inbox"),
Layers: icon("Layers"),
RotateCw: icon("RotateCw"), RotateCw: icon("RotateCw"),
}; };
}); });
@@ -81,35 +83,38 @@ vi.mock("@/components/ui/loading-spinner", () => ({
React.createElement("span", { "data-testid": "import-session-loading-spinner" }), React.createElement("span", { "data-testid": "import-session-loading-spinner" }),
})); }));
vi.mock("@/components/ui/segmented-control", () => ({ vi.mock("@/components/ui/combobox", () => ({
SegmentedControl: ({ Combobox: ({
options, options,
value, value,
onValueChange, onSelect,
testID, open,
}: { }: {
options: ReadonlyArray<{ value: string; label: string; testID?: string }>; options: ReadonlyArray<{ id: string; label: string }>;
value: string; value: string;
onValueChange: (value: string) => void; onSelect: (id: string) => void;
testID?: string; open?: boolean;
}) => }) => {
React.createElement( if (!open) return null;
return React.createElement(
"div", "div",
{ "data-testid": testID }, { "data-testid": "import-session-combobox" },
options.map((option) => options.map((option) =>
React.createElement( React.createElement(
"button", "button",
{ {
key: option.value, key: option.id,
type: "button", type: "button",
"data-testid": option.testID, "data-testid": `import-session-filter-${option.id === "__all__" ? "all" : option.id}`,
"data-selected": value === option.value, "data-selected": value === option.id,
onClick: () => onValueChange(option.value), onClick: () => onSelect(option.id),
}, },
option.label, option.label,
), ),
), ),
), );
},
ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label),
})); }));
vi.mock("@/components/adaptive-modal-sheet", () => ({ 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 () => { it("imports a selected session by provider handle and reports the imported agent", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({ const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions", 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 importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
const onClose = vi.fn(); const onClose = vi.fn();
@@ -499,7 +510,7 @@ describe("ImportSessionSheet", () => {
expect(importAgent).toHaveBeenCalledWith({ expect(importAgent).toHaveBeenCalledWith({
providerId: "claude", providerId: "claude",
providerHandleId: "provider-thread-1", providerHandleId: "provider-thread-1",
cwd: "/repo/paseo", cwd: "/repo/paseo-realpath",
}); });
}); });
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported"); expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
@@ -678,11 +689,13 @@ describe("ImportSessionSheet", () => {
await screen.findByText("Session claude"); await screen.findByText("Session claude");
await screen.findByText("Session codex"); await screen.findByText("Session codex");
fireEvent.click(screen.getByTestId("import-session-filter-trigger"));
fireEvent.click(screen.getByTestId("import-session-filter-codex")); fireEvent.click(screen.getByTestId("import-session-filter-codex"));
screen.getByText("Session codex"); screen.getByText("Session codex");
expect(screen.queryByText("Session claude")).toBeNull(); expect(screen.queryByText("Session claude")).toBeNull();
fireEvent.click(screen.getByTestId("import-session-filter-trigger"));
fireEvent.click(screen.getByTestId("import-session-filter-all")); fireEvent.click(screen.getByTestId("import-session-filter-all"));
screen.getByText("Session claude"); screen.getByText("Session claude");
@@ -1,16 +1,16 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native"; import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { import type {
DaemonClient, DaemonClient,
FetchRecentProviderSessionEntry, FetchRecentProviderSessionEntry,
} from "@getpaseo/client/internal/daemon-client"; } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; 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 { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -88,6 +88,7 @@ interface SheetStatusMessagesProps {
isSnapshotUnsupported: boolean; isSnapshotUnsupported: boolean;
hasNoImportableProviders: boolean; hasNoImportableProviders: boolean;
isLoadingSessions: boolean; isLoadingSessions: boolean;
hasRows: boolean;
allQueriesErrored: boolean; allQueriesErrored: boolean;
erroredProviderLabels: ReadonlyArray<string>; erroredProviderLabels: ReadonlyArray<string>;
importErrored: boolean; importErrored: boolean;
@@ -98,6 +99,7 @@ function SheetStatusMessages({
isSnapshotUnsupported, isSnapshotUnsupported,
hasNoImportableProviders, hasNoImportableProviders,
isLoadingSessions, isLoadingSessions,
hasRows,
allQueriesErrored, allQueriesErrored,
erroredProviderLabels, erroredProviderLabels,
importErrored, importErrored,
@@ -114,7 +116,7 @@ function SheetStatusMessages({
{hasNoImportableProviders ? ( {hasNoImportableProviders ? (
<Text style={styles.statusText}>No importable providers are enabled.</Text> <Text style={styles.statusText}>No importable providers are enabled.</Text>
) : null} ) : null}
{isLoadingSessions ? ( {isLoadingSessions && !hasRows ? (
<View style={styles.statusRow}> <View style={styles.statusRow}>
<LoadingSpinner color={theme.colors.foregroundMuted} /> <LoadingSpinner color={theme.colors.foregroundMuted} />
<Text style={styles.statusText}>Loading recent sessions...</Text> <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({ function ImportSessionSheetRow({
entry, entry,
disabled, disabled,
@@ -271,6 +254,7 @@ export function ImportSessionSheet({
onImported, onImported,
}: ImportSessionSheetProps) { }: ImportSessionSheetProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { theme } = useUnistyles();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, { const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
cwd, cwd,
@@ -315,6 +299,8 @@ export function ImportSessionSheet({
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]); const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE); const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
useEffect(() => { useEffect(() => {
if ( if (
@@ -330,24 +316,84 @@ export function ImportSessionSheet({
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider); return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
}, [aggregatedEntries, selectedProvider]); }, [aggregatedEntries, selectedProvider]);
const filterOptions = useMemo( const filterComboboxOptions = useMemo<ComboboxOption[]>(
() => buildProviderFilterOptions(filterProviders, providerLabelById), () => [
{ id: ALL_FILTER_VALUE, label: "All providers" },
...filterProviders.map((provider) => ({
id: provider,
label: providerLabelById.get(provider) ?? provider,
})),
],
[filterProviders, providerLabelById], [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({ const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => { mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error("Host is not connected");
} }
const effectiveCwd = cwd ?? entry.cwd; if (!entry.cwd) {
if (!effectiveCwd) {
throw new Error("Session is missing a working directory"); throw new Error("Session is missing a working directory");
} }
const agent = await client.importAgent({ const agent = await client.importAgent({
providerId: entry.providerId, providerId: entry.providerId,
providerHandleId: entry.providerHandleId, providerHandleId: entry.providerHandleId,
cwd: effectiveCwd, cwd: entry.cwd,
}); });
return agent; return agent;
}, },
@@ -423,25 +469,48 @@ export function ImportSessionSheet({
snapPoints={IMPORT_SHEET_SNAP_POINTS} snapPoints={IMPORT_SHEET_SNAP_POINTS}
> >
{showFilter ? ( {showFilter ? (
<ScrollView <View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
horizontal <Pressable
showsHorizontalScrollIndicator={false} onPress={handleFilterOpen}
contentContainerStyle={styles.filterRow} style={filterTriggerStyle}
> testID="import-session-filter-trigger"
<SegmentedControl accessibilityRole="button"
testID="import-session-filters" accessibilityLabel={`Filter: ${selectedProviderLabel}`}
size="sm" >
options={filterOptions} {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} 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} ) : null}
<SheetStatusMessages <SheetStatusMessages
isClientReady={Boolean(client)} isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported} isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders} hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions} isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
allQueriesErrored={allQueriesErrored} allQueriesErrored={allQueriesErrored}
erroredProviderLabels={erroredProviderLabels} erroredProviderLabels={erroredProviderLabels}
importErrored={importMutation.isError} importErrored={importMutation.isError}
@@ -466,10 +535,32 @@ export function ImportSessionSheet({
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
filterRow: { filterTriggerWrap: {
flexDirection: "row",
paddingBottom: theme.spacing[2], 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: { list: {
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createTestLogger } from "../../test-utils/test-logger.js"; 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 { AgentStorage } from "./agent-storage.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { formatSystemNotificationPrompt } from "./agent-prompt.js"; import { formatSystemNotificationPrompt } from "./agent-prompt.js";
@@ -25,7 +25,7 @@ import type {
AgentSessionConfig, AgentSessionConfig,
AgentStreamEvent, AgentStreamEvent,
AgentTimelineItem, AgentTimelineItem,
PersistedAgentDescriptor, ImportProviderSessionInput,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import type { ProviderDefinition } from "./provider-registry.js"; import type { ProviderDefinition } from "./provider-registry.js";
@@ -48,6 +48,7 @@ function deferred<T>(): Deferred<T> {
const TEST_CAPABILITIES = { const TEST_CAPABILITIES = {
supportsStreaming: false, supportsStreaming: false,
supportsSessionPersistence: false, supportsSessionPersistence: false,
supportsSessionListing: true,
supportsDynamicModes: false, supportsDynamicModes: false,
supportsMcpServers: false, supportsMcpServers: false,
supportsReasoningStream: 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( function expectArchivedAgentRecord(
record: StoredAgentRecord | null, record: StoredAgentRecord | null,
expectedLastStatus: "closed" | "idle", 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 () => { test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-find-persisted-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger); const storage = new AgentStorage(storagePath, logger);
const session = new TestAgentSession({ provider: "codex", cwd: workdir });
const events: AgentManagerEvent[] = [];
const descriptors: PersistedAgentDescriptor[] = [ class ImportClient extends TestAgentClient {
createPersistedDescriptor({ listCalls = 0;
cwd: workdir, importInput: unknown = null;
sessionId: "session-direct",
nativeHandle: "native-direct",
}),
createPersistedDescriptor({
cwd: workdir,
sessionId: "session-other",
nativeHandle: "native-match",
}),
];
class PersistedAgentsClient extends TestAgentClient { async listImportableSessions() {
lastLimit: number | undefined; this.listCalls += 1;
lastCwd: string | undefined; return [];
}
override async listPersistedAgents(options?: { limit?: number; cwd?: string }) { async importSession(input: ImportProviderSessionInput) {
this.lastLimit = options?.limit; this.importInput = input;
this.lastCwd = options?.cwd; return {
return descriptors; 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({ const manager = new AgentManager({
clients: { clients: {
codex: client, codex: client,
@@ -1508,15 +1497,32 @@ test("findPersistedAgent returns matching descriptors by session id or native ha
registry: storage, registry: storage,
logger, logger,
}); });
manager.subscribe((event) => events.push(event), { replayState: false });
await expect(manager.findPersistedAgent("codex", "session-direct")).resolves.toBe(descriptors[0]); const imported = await manager.importProviderSession({
await expect(manager.findPersistedAgent("codex", "native-match")).resolves.toBe(descriptors[1]); provider: "codex",
await expect(manager.findPersistedAgent("codex", "missing")).resolves.toBeNull(); providerHandleId: "thread-selected",
await expect( cwd: workdir,
manager.findPersistedAgent("codex", "session-direct", { cwd: "/tmp/project" }), });
).resolves.toBe(descriptors[0]);
expect(client.lastLimit).toBe(200); expect(client.listCalls).toBe(0);
expect(client.lastCwd).toBe("/tmp/project"); 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 () => { test("reloadAgentSession passes daemon launch env through the provider launch context", async () => {
@@ -5711,17 +5717,16 @@ class RecordingPersistedAgentsClient implements AgentClient {
return []; return [];
} }
async listPersistedAgents(): Promise<PersistedAgentDescriptor[]> { async listImportableSessions() {
this.calls += 1; this.calls += 1;
return [ return [
{ {
provider: this.provider, providerHandleId: `${this.provider}-session`,
sessionId: `${this.provider}-session`,
cwd: "/tmp/recent", cwd: "/tmp/recent",
title: null, title: null,
lastActivityAt: new Date("2026-01-01T00:00:00Z"), lastActivityAt: new Date("2026-01-01T00:00:00Z"),
persistence: { provider: this.provider, sessionId: `${this.provider}-session` }, firstPromptPreview: null,
timeline: [], 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) => { async (_reason, includedProvider, skippedProvider, providerDefinitions) => {
const includedClient = new RecordingPersistedAgentsClient(includedProvider); const includedClient = new RecordingPersistedAgentsClient(includedProvider);
const skippedClient = new RecordingPersistedAgentsClient(skippedProvider); const skippedClient = new RecordingPersistedAgentsClient(skippedProvider);
@@ -5748,7 +5753,7 @@ test.each([
logger, logger,
}); });
const result = await manager.listImportablePersistedAgents(); const result = await manager.listImportableSessions();
expect(includedClient.calls).toBe(1); expect(includedClient.calls).toBe(1);
expect(skippedClient.calls).toBe(0); 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 claudeClient = new RecordingPersistedAgentsClient("claude");
const ompClient = new RecordingPersistedAgentsClient("omp"); const ompClient = new RecordingPersistedAgentsClient("omp");
const manager = new AgentManager({ const manager = new AgentManager({
@@ -5768,14 +5773,14 @@ test("listImportablePersistedAgents includes derived providers that list persist
logger, logger,
}); });
const result = await manager.listImportablePersistedAgents(); const result = await manager.listImportableSessions();
expect(claudeClient.calls).toBe(1); expect(claudeClient.calls).toBe(1);
expect(ompClient.calls).toBe(1); expect(ompClient.calls).toBe(1);
expect(result.map((d) => d.provider).sort()).toEqual(["claude", "omp"]); 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 claudeClient = new RecordingPersistedAgentsClient("claude");
const codexClient = new RecordingPersistedAgentsClient("codex"); const codexClient = new RecordingPersistedAgentsClient("codex");
const manager = new AgentManager({ const manager = new AgentManager({
@@ -5787,7 +5792,7 @@ test("listImportablePersistedAgents narrows to the providerFilter when supplied"
logger, logger,
}); });
const result = await manager.listImportablePersistedAgents({ const result = await manager.listImportableSessions({
providerFilter: new Set(["claude"]), providerFilter: new Set(["claude"]),
}); });
@@ -5796,6 +5801,33 @@ test("listImportablePersistedAgents narrows to the providerFilter when supplied"
expect(result.map((d) => d.provider)).toEqual(["claude"]); 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 () => { 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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
+134 -42
View File
@@ -33,8 +33,9 @@ import {
type AgentTimelineItem, type AgentTimelineItem,
type AgentUsage, type AgentUsage,
type AgentRuntimeInfo, type AgentRuntimeInfo,
type ListPersistedAgentsOptions, type ImportedTimelineEntry,
type PersistedAgentDescriptor, type ImportableProviderSession,
type ListImportableSessionsOptions,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.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 { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { isSystemInjectedEnvelope } from "./agent-prompt.js"; import { isSystemInjectedEnvelope } from "./agent-prompt.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.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 RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
@@ -146,7 +148,7 @@ interface HydrateTimelineOptions {
broadcast?: boolean; broadcast?: boolean;
} }
export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & { export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & {
/** /**
* When set, only providers in this set are scanned, in addition to the * When set, only providers in this set are scanned, in addition to the
* built-in importable allowlist + enabled + non-derived rules. * built-in importable allowlist + enabled + non-derived rules.
@@ -154,6 +156,10 @@ export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions &
providerFilter?: Set<string>; providerFilter?: Set<string>;
}; };
export interface ManagedImportableProviderSession extends ImportableProviderSession {
provider: AgentProvider;
}
export type AgentAttentionCallback = (params: { export type AgentAttentionCallback = (params: {
agentId: string; agentId: string;
provider: AgentProvider; 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 { export class AgentManager {
private readonly clients = new Map<AgentProvider, AgentClient>(); private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>(); private readonly providerEnabled = new Map<AgentProvider, boolean>();
@@ -612,34 +662,37 @@ export class AgentManager {
.map((agent) => Object.assign({}, agent)); .map((agent) => Object.assign({}, agent));
} }
async listImportablePersistedAgents( async listImportableSessions(
options?: ImportablePersistedAgentQueryOptions, options?: ImportablePersistedAgentQueryOptions,
): Promise<PersistedAgentDescriptor[]> { ): Promise<ManagedImportableProviderSession[]> {
const providerEntries = Array.from(this.clients.entries()).filter( const providerEntries = Array.from(this.clients.entries()).filter(
([provider, client]) => ([provider, client]) =>
!!client.listPersistedAgents && client.capabilities.supportsSessionListing &&
!!client.listImportableSessions &&
this.isProviderImportable(provider, options?.providerFilter), this.isProviderImportable(provider, options?.providerFilter),
); );
const descriptorLists = await Promise.all( const sessionLists = await Promise.all(
providerEntries.map(async ([provider, client]) => { providerEntries.map(async ([provider, client]) => {
try { try {
return await client.listPersistedAgents!({ return (
limit: options?.limit, await client.listImportableSessions!({
cwd: options?.cwd, limit: options?.limit,
}); cwd: options?.cwd,
})
).map((session) => Object.assign(session, { provider }));
} catch (error) { } catch (error) {
this.logger.warn( this.logger.warn(
{ err: error, provider }, { err: error, provider },
"Failed to list persisted agents for provider", "Failed to list importable sessions for provider",
); );
return []; return [];
} }
}), }),
); );
const descriptors: PersistedAgentDescriptor[] = descriptorLists.flat(); const sessions: ManagedImportableProviderSession[] = sessionLists.flat();
const limit = options?.limit ?? 20; const limit = options?.limit ?? 20;
return descriptors return sessions
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()) .sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())
.slice(0, limit); .slice(0, limit);
} }
@@ -657,26 +710,6 @@ export class AgentManager {
return true; 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[]> { async listProviderAvailability(): Promise<ProviderAvailability[]> {
const checks = Array.from(this.clients.keys()).map(async (provider) => { const checks = Array.from(this.clients.keys()).map(async (provider) => {
const client = this.clients.get(provider); const client = this.clients.get(provider);
@@ -871,6 +904,50 @@ export class AgentManager {
return this.registerSession(session, storedConfig, resolvedAgentId, options); 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 // Hot-reload an active agent session with config overrides. By default the
// in-memory timeline is preserved (used for voice-mode toggles and similar // in-memory timeline is preserved (used for voice-mode toggles and similar
// config swaps). When `rehydrateFromDisk` is set, the timeline is wiped so a // config swaps). When `rehydrateFromDisk` is set, the timeline is wiped so a
@@ -2236,11 +2313,13 @@ export class AgentManager {
timeline?: AgentTimelineItem[]; timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[]; timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number; timelineNextSeq?: number;
persistence?: AgentPersistenceHandle;
historyPrimed?: boolean; historyPrimed?: boolean;
lastUsage?: AgentUsage; lastUsage?: AgentUsage;
lastError?: string; lastError?: string;
attention?: AttentionState; attention?: AttentionState;
initialTitle?: string | null; initialTitle?: string | null;
publishWhenReady?: boolean;
}, },
): Promise<ManagedAgent> { ): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(agentId, "registerSession"); const resolvedAgentId = validateAgentId(agentId, "registerSession");
@@ -2272,14 +2351,16 @@ export class AgentManager {
this.agents.set(resolvedAgentId, managed); this.agents.set(resolvedAgentId, managed);
// Initialize previousStatus to track transitions // Initialize previousStatus to track transitions
this.previousStatuses.set(resolvedAgentId, managed.lifecycle); this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
await this.refreshRuntimeInfo(managed); await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
await this.persistSnapshot(managed, { await this.persistSnapshot(managed, {
workspaceId: options?.workspaceId, workspaceId: options?.workspaceId,
title: initialPersistedTitle, 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"; managed.lifecycle = "idle";
await this.persistSnapshot(managed, { workspaceId: options?.workspaceId }); await this.persistSnapshot(managed, { workspaceId: options?.workspaceId });
this.emitState(managed, { persist: false }); this.emitState(managed, { persist: false });
@@ -2295,6 +2376,7 @@ export class AgentManager {
timeline?: AgentTimelineItem[]; timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[]; timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number; timelineNextSeq?: number;
persistence?: AgentPersistenceHandle;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
} }
@@ -2337,6 +2419,7 @@ export class AgentManager {
lastUsage?: AgentUsage; lastUsage?: AgentUsage;
lastError?: string; lastError?: string;
attention?: AttentionState; attention?: AttentionState;
persistence?: AgentPersistenceHandle;
} }
| undefined; | undefined;
}): ActiveManagedAgent { }): ActiveManagedAgent {
@@ -2362,7 +2445,10 @@ export class AgentManager {
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(), foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
finalizedForegroundTurnIds: new Set<string>(), finalizedForegroundTurnIds: new Set<string>(),
unsubscribeSession: null, unsubscribeSession: null,
persistence: attachPersistenceCwd(session.describePersistence(), config.cwd), persistence: attachPersistenceCwd(
options?.persistence ?? session.describePersistence(),
config.cwd,
),
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows, historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
lastUserMessageAt: options?.lastUserMessageAt ?? null, lastUserMessageAt: options?.lastUserMessageAt ?? null,
lastUsage: options?.lastUsage, lastUsage: options?.lastUsage,
@@ -2560,7 +2646,10 @@ export class AgentManager {
return this.registry; return this.registry;
} }
private async refreshSessionState(agent: ActiveManagedAgent): Promise<void> { private async refreshSessionState(
agent: ActiveManagedAgent,
options?: { emit?: boolean },
): Promise<void> {
try { try {
const modes = await agent.session.getAvailableModes(); const modes = await agent.session.getAvailableModes();
agent.availableModes = modes; agent.availableModes = modes;
@@ -2582,10 +2671,13 @@ export class AgentManager {
} }
this.syncFeaturesFromSession(agent); 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 { try {
const newInfo = await agent.session.getRuntimeInfo(); const newInfo = await agent.session.getRuntimeInfo();
const changed = const changed =
@@ -2601,7 +2693,7 @@ export class AgentManager {
); );
} }
// Emit state if runtimeInfo changed so clients get the updated model // Emit state if runtimeInfo changed so clients get the updated model
if (changed) { if (changed && options?.emit !== false) {
this.emitState(agent); this.emitState(agent);
} }
} catch { } catch {
@@ -10,10 +10,10 @@ import {
import type { AgentSession } from "./agent-sdk-types.js"; import type { AgentSession } from "./agent-sdk-types.js";
import type { import type {
AgentFeature, AgentFeature,
ImportableProviderSession,
AgentPermissionRequest, AgentPermissionRequest,
AgentPersistenceHandle, AgentPersistenceHandle,
AgentSessionConfig, AgentSessionConfig,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
type ManagedAgentOverrides = Omit<Partial<ManagedAgent>, "config" | "pendingPermissions"> & { type ManagedAgentOverrides = Omit<Partial<ManagedAgent>, "config" | "pendingPermissions"> & {
@@ -400,26 +400,18 @@ describe("toAgentPayload", () => {
}); });
describe("toRecentProviderSessionDescriptorPayload", () => { describe("toRecentProviderSessionDescriptorPayload", () => {
it("projects persisted descriptors to provider-opaque public recent sessions", () => { it("projects provider import rows to provider-opaque public recent sessions", () => {
const descriptor: PersistedAgentDescriptor = { const session: ImportableProviderSession & { provider: string } = {
provider: "codex-custom", provider: "codex-custom",
sessionId: "legacy-session-id", providerHandleId: "provider-native-handle",
cwd: "/tmp/project", cwd: "/tmp/project",
title: "Import me", title: "Import me",
firstPromptPreview: "First prompt with spacing",
lastPromptPreview: "Second prompt",
lastActivityAt: new Date("2026-04-30T12:34:56.000Z"), 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", providerLabel: "Custom Codex",
}); });
@@ -438,28 +430,25 @@ describe("toRecentProviderSessionDescriptorPayload", () => {
expect(payload).not.toHaveProperty("nativeHandle"); expect(payload).not.toHaveProperty("nativeHandle");
}); });
it("falls back to persistence session id when no provider native handle exists", () => { it("preserves null prompt previews", () => {
const descriptor: PersistedAgentDescriptor = { const session: ImportableProviderSession & { provider: string } = {
provider: "claude-custom", provider: "claude-custom",
sessionId: "descriptor-session-id", providerHandleId: "provider-session-id",
cwd: "/tmp/project", cwd: "/tmp/project",
title: null, title: null,
lastActivityAt: new Date("2026-04-30T12:34:56.000Z"), lastActivityAt: new Date("2026-04-30T12:34:56.000Z"),
persistence: { firstPromptPreview: null,
provider: "claude-custom", lastPromptPreview: null,
sessionId: "persistence-session-id",
},
timeline: [],
}; };
expect( expect(
toRecentProviderSessionDescriptorPayload(descriptor, { toRecentProviderSessionDescriptorPayload(session, {
providerLabel: "Custom Claude", providerLabel: "Custom Claude",
}), }),
).toMatchObject({ ).toMatchObject({
providerId: "claude-custom", providerId: "claude-custom",
providerLabel: "Custom Claude", providerLabel: "Custom Claude",
providerHandleId: "persistence-session-id", providerHandleId: "provider-session-id",
firstPromptPreview: null, firstPromptPreview: null,
lastPromptPreview: null, lastPromptPreview: null,
}); });
@@ -14,9 +14,8 @@ import type {
AgentProvider, AgentProvider,
AgentSessionConfig, AgentSessionConfig,
AgentRuntimeInfo, AgentRuntimeInfo,
AgentTimelineItem,
AgentUsage, AgentUsage,
PersistedAgentDescriptor, ImportableProviderSession,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js"; import type { ManagedAgent } from "./agent-manager.js";
import type { JsonValue } from "../json-utils.js"; import type { JsonValue } from "../json-utils.js";
@@ -33,8 +32,6 @@ interface RecentProviderSessionProjectionOptions {
providerLabel: string; providerLabel: string;
} }
const PROMPT_PREVIEW_MAX_LENGTH = 160;
function normalizeThinkingOptionId(value: string | null | undefined): string | null { function normalizeThinkingOptionId(value: string | null | undefined): string | null {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
const normalized = value.trim(); const normalized = value.trim();
@@ -263,20 +260,18 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
} }
export function toRecentProviderSessionDescriptorPayload( export function toRecentProviderSessionDescriptorPayload(
descriptor: PersistedAgentDescriptor, session: ImportableProviderSession & { provider: string },
options: RecentProviderSessionProjectionOptions, options: RecentProviderSessionProjectionOptions,
): RecentProviderSessionDescriptorPayload { ): RecentProviderSessionDescriptorPayload {
const promptPreviews = collectPromptPreviews(descriptor.timeline);
return { return {
providerId: descriptor.provider, providerId: session.provider,
providerLabel: options.providerLabel, providerLabel: options.providerLabel,
providerHandleId: descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId, providerHandleId: session.providerHandleId,
cwd: descriptor.cwd, cwd: session.cwd,
title: descriptor.title, title: session.title,
firstPromptPreview: promptPreviews[0] ?? null, firstPromptPreview: session.firstPromptPreview,
lastPromptPreview: promptPreviews.at(-1) ?? null, lastPromptPreview: session.lastPromptPreview,
lastActivityAt: descriptor.lastActivityAt.toISOString(), lastActivityAt: session.lastActivityAt.toISOString(),
}; };
} }
@@ -297,26 +292,6 @@ export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): s
return timestamps[0].raw; 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 { function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {}; const serializable: SerializableAgentConfig = {};
if (config.modeId) { if (config.modeId) {
@@ -162,6 +162,7 @@ export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
export interface AgentCapabilityFlags { export interface AgentCapabilityFlags {
supportsStreaming: boolean; supportsStreaming: boolean;
supportsSessionPersistence: boolean; supportsSessionPersistence: boolean;
supportsSessionListing?: boolean;
supportsDynamicModes: boolean; supportsDynamicModes: boolean;
supportsMcpServers: boolean; supportsMcpServers: boolean;
supportsReasoningStream: boolean; supportsReasoningStream: boolean;
@@ -494,25 +495,45 @@ export interface AgentSlashCommand {
kind?: AgentSlashCommandKind; kind?: AgentSlashCommandKind;
} }
export interface ListPersistedAgentsOptions { export interface ListImportableSessionsOptions {
limit?: number; limit?: number;
/** /**
* Optional cwd hint. Providers that can cheaply pre-filter persisted * Optional cwd hint. Providers that can cheaply pre-filter importable
* sessions by working directory should do so before doing expensive * sessions by working directory should do so before doing expensive work.
* work like fetching turn timelines. Providers that can't filter
* cheaply may ignore this hint.
*/ */
cwd?: string; cwd?: string;
} }
export interface PersistedAgentDescriptor { export interface ImportableProviderSession {
provider: AgentProvider; providerHandleId: string;
sessionId: string;
cwd: string; cwd: string;
title: string | null; title: string | null;
firstPromptPreview: string | null;
lastPromptPreview: string | null;
lastActivityAt: Date; 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; persistence: AgentPersistenceHandle;
timeline: AgentTimelineItem[]; timeline: ImportedTimelineEntry[];
} }
export interface AgentSessionConfig { export interface AgentSessionConfig {
@@ -640,7 +661,13 @@ export interface AgentClient {
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean; isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>; listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>; 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). * Check if this provider is available (CLI binary is installed).
* Returns true if available, false otherwise. * Returns true if available, false otherwise.
@@ -2,10 +2,14 @@ import { beforeEach, expect, test, vi } from "vitest";
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; 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 { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { FetchRecentProviderSessionsRequestMessage } from "@getpaseo/protocol/messages"; 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 { import {
ImportSessionsRequestError, ImportSessionsRequestError,
importProviderSession, importProviderSession,
@@ -28,7 +32,7 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
function makeDescriptor(args: { function makeImportableSession(args: {
provider?: string; provider?: string;
sessionId: string; sessionId: string;
nativeHandle?: string; nativeHandle?: string;
@@ -37,25 +41,17 @@ function makeDescriptor(args: {
lastActivityAt: string; lastActivityAt: string;
firstPrompt?: string; firstPrompt?: string;
lastPrompt?: string; lastPrompt?: string;
}): PersistedAgentDescriptor { }): ManagedImportableProviderSession {
const provider = args.provider ?? "codex"; const provider = args.provider ?? "codex";
const cwd = args.cwd ?? "/tmp/project"; const cwd = args.cwd ?? "/tmp/project";
return { return {
provider, provider,
sessionId: args.sessionId, providerHandleId: args.nativeHandle ?? args.sessionId,
cwd, cwd,
title: args.title ?? null, title: args.title ?? null,
lastActivityAt: new Date(args.lastActivityAt), lastActivityAt: new Date(args.lastActivityAt),
persistence: { firstPromptPreview: args.firstPrompt ?? null,
provider, lastPromptPreview: args.lastPrompt ?? args.firstPrompt ?? null,
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 }] : []),
],
}; };
} }
@@ -114,15 +110,15 @@ function makeRequest(
test("listImportableProviderSessions filters, sorts, limits, and projects importable sessions", async () => { test("listImportableProviderSessions filters, sorts, limits, and projects importable sessions", async () => {
const cwd = "/tmp/project"; const cwd = "/tmp/project";
const descriptors = [ const sessions = [
makeDescriptor({ makeImportableSession({
sessionId: "outside-cwd", sessionId: "outside-cwd",
nativeHandle: "outside-cwd-handle", nativeHandle: "outside-cwd-handle",
cwd: "/tmp/elsewhere", cwd: "/tmp/elsewhere",
title: "Outside cwd", title: "Outside cwd",
lastActivityAt: "2026-04-30T12:05:00.000Z", lastActivityAt: "2026-04-30T12:05:00.000Z",
}), }),
makeDescriptor({ makeImportableSession({
sessionId: "stored-session", sessionId: "stored-session",
nativeHandle: "stored-handle", nativeHandle: "stored-handle",
cwd, cwd,
@@ -130,14 +126,14 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
lastActivityAt: "2026-04-30T12:04:00.000Z", lastActivityAt: "2026-04-30T12:04:00.000Z",
firstPrompt: "stored prompt", firstPrompt: "stored prompt",
}), }),
makeDescriptor({ makeImportableSession({
sessionId: "older-session", sessionId: "older-session",
nativeHandle: "older-handle", nativeHandle: "older-handle",
cwd, cwd,
title: "Older than since", title: "Older than since",
lastActivityAt: "2026-04-29T23:59:59.000Z", lastActivityAt: "2026-04-29T23:59:59.000Z",
}), }),
makeDescriptor({ makeImportableSession({
sessionId: "newer-session", sessionId: "newer-session",
nativeHandle: "newer-handle", nativeHandle: "newer-handle",
cwd, cwd,
@@ -146,7 +142,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
firstPrompt: "newer first prompt", firstPrompt: "newer first prompt",
lastPrompt: "newer last prompt", lastPrompt: "newer last prompt",
}), }),
makeDescriptor({ makeImportableSession({
sessionId: "second-session", sessionId: "second-session",
nativeHandle: "second-handle", nativeHandle: "second-handle",
cwd, cwd,
@@ -154,7 +150,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
lastActivityAt: "2026-04-30T12:00:00.000Z", lastActivityAt: "2026-04-30T12:00:00.000Z",
firstPrompt: "second prompt", firstPrompt: "second prompt",
}), }),
makeDescriptor({ makeImportableSession({
sessionId: "third-session", sessionId: "third-session",
nativeHandle: "third-handle", nativeHandle: "third-handle",
cwd, cwd,
@@ -162,7 +158,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
lastActivityAt: "2026-04-30T11:59:00.000Z", lastActivityAt: "2026-04-30T11:59:00.000Z",
firstPrompt: "third prompt", firstPrompt: "third prompt",
}), }),
makeDescriptor({ makeImportableSession({
sessionId: "live-session", sessionId: "live-session",
nativeHandle: "live-handle", nativeHandle: "live-handle",
cwd, cwd,
@@ -171,7 +167,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
firstPrompt: "live prompt", firstPrompt: "live prompt",
}), }),
]; ];
const listImportablePersistedAgents = vi.fn(async () => descriptors); const listImportableSessions = vi.fn(async () => sessions);
const agentManager = { const agentManager = {
listAgents: () => listAgents: () =>
[ [
@@ -184,8 +180,8 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
}, },
}, },
] as ManagedAgent[], ] as ManagedAgent[],
listImportablePersistedAgents, listImportableSessions,
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">; } satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">;
const agentStorage = { const agentStorage = {
list: async () => [ list: async () => [
{ {
@@ -211,7 +207,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
providerSnapshotManager: { getProviderLabel: () => "Codex" }, providerSnapshotManager: { getProviderLabel: () => "Codex" },
}); });
expect(listImportablePersistedAgents).toHaveBeenCalledWith({ expect(listImportableSessions).toHaveBeenCalledWith({
limit: 2, limit: 2,
providerFilter: new Set(["codex"]), providerFilter: new Set(["codex"]),
cwd, cwd,
@@ -245,8 +241,8 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
test("listImportableProviderSessions filters out metadata generation sessions", async () => { test("listImportableProviderSessions filters out metadata generation sessions", async () => {
const cwd = "/tmp/project"; const cwd = "/tmp/project";
const descriptors = [ const sessions = [
makeDescriptor({ makeImportableSession({
sessionId: "metadata-session", sessionId: "metadata-session",
nativeHandle: "metadata-handle", nativeHandle: "metadata-handle",
cwd, cwd,
@@ -255,7 +251,7 @@ test("listImportableProviderSessions filters out metadata generation sessions",
firstPrompt: firstPrompt:
"Generate metadata for a coding agent based on the user prompt.\nTitle: short descriptive label (<= 40 chars).", "Generate metadata for a coding agent based on the user prompt.\nTitle: short descriptive label (<= 40 chars).",
}), }),
makeDescriptor({ makeImportableSession({
sessionId: "real-session", sessionId: "real-session",
nativeHandle: "real-handle", nativeHandle: "real-handle",
cwd, cwd,
@@ -269,8 +265,8 @@ test("listImportableProviderSessions filters out metadata generation sessions",
request: makeRequest({ cwd, providers: ["codex"] }), request: makeRequest({ cwd, providers: ["codex"] }),
agentManager: { agentManager: {
listAgents: () => [], listAgents: () => [],
listImportablePersistedAgents: async () => descriptors, listImportableSessions: async () => sessions,
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">, } satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: { agentStorage: {
list: async () => [], list: async () => [],
} satisfies Pick<AgentStorage, "list">, } satisfies Pick<AgentStorage, "list">,
@@ -294,8 +290,8 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy
request: makeRequest({ cwd: linkedCwd, providers: ["pi"] }), request: makeRequest({ cwd: linkedCwd, providers: ["pi"] }),
agentManager: { agentManager: {
listAgents: () => [], listAgents: () => [],
listImportablePersistedAgents: async () => [ listImportableSessions: async () => [
makeDescriptor({ makeImportableSession({
provider: "pi", provider: "pi",
sessionId: "pi-session", sessionId: "pi-session",
nativeHandle: "pi-handle", nativeHandle: "pi-handle",
@@ -305,7 +301,7 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy
firstPrompt: "remember this", firstPrompt: "remember this",
}), }),
], ],
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">, } satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: { agentStorage: {
list: async () => [], list: async () => [],
} satisfies Pick<AgentStorage, "list">, } satisfies Pick<AgentStorage, "list">,
@@ -321,8 +317,8 @@ test("listImportableProviderSessions rejects invalid since values", async () =>
request: makeRequest({ since: "not-a-date" }), request: makeRequest({ since: "not-a-date" }),
agentManager: { agentManager: {
listAgents: () => [], listAgents: () => [],
listImportablePersistedAgents: async () => [], listImportableSessions: async () => [],
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">, } satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: { agentStorage: {
list: async () => [], list: async () => [],
} satisfies Pick<AgentStorage, "list">, } 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 cwd = "/tmp/imported-agent";
const timeline: AgentTimelineItem[] = [ const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" }, { 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", nativeHandle: "provider-thread-imported",
title: null, 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 = { const agentManager = {
findPersistedAgent: vi.fn().mockResolvedValue(descriptor), importProviderSession: vi.fn().mockResolvedValue(snapshot),
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
getTimeline: vi.fn().mockReturnValue(timeline), getTimeline: vi.fn().mockReturnValue(timeline),
setTitle: vi.fn().mockResolvedValue(undefined),
notifyAgentState: vi.fn(),
} as unknown as AgentManager; } as unknown as AgentManager;
const agentStorage = { const agentStorage = {
list: vi.fn().mockResolvedValue([]), list: vi.fn().mockResolvedValue([]),
@@ -411,19 +394,12 @@ test("importProviderSession resumes by provider handle, hydrates the timeline, a
deps: { scheduleAgentMetadataGeneration }, deps: { scheduleAgentMetadataGeneration },
}); });
expect(agentManager.findPersistedAgent).toHaveBeenCalledWith( expect(agentManager.importProviderSession).toHaveBeenCalledWith({
"custom-codex", provider: "custom-codex",
"provider-thread-imported", providerHandleId: "provider-thread-imported",
{ cwd }, cwd,
); labels: undefined,
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(scheduleAgentMetadataGeneration).toHaveBeenCalledWith( expect(scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
agentManager, agentManager,
@@ -436,7 +412,7 @@ test("importProviderSession resumes by provider handle, hydrates the timeline, a
expect(result).toEqual({ snapshot, timelineSize: 2 }); 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 cwd = "/tmp/imported-agent";
const snapshot = makeManagedAgent({ const snapshot = makeManagedAgent({
provider: "codex", provider: "codex",
@@ -445,12 +421,8 @@ test("importProviderSession builds a fallback handle when a non-OpenCode provide
nativeHandle: "thread-imported", nativeHandle: "thread-imported",
}); });
const agentManager = { const agentManager = {
findPersistedAgent: vi.fn().mockResolvedValue(null), importProviderSession: vi.fn().mockResolvedValue(snapshot),
resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot),
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
getTimeline: vi.fn().mockReturnValue([]), getTimeline: vi.fn().mockReturnValue([]),
setTitle: vi.fn().mockResolvedValue(undefined),
notifyAgentState: vi.fn(),
} as unknown as AgentManager; } as unknown as AgentManager;
const agentStorage = { const agentStorage = {
list: vi.fn().mockResolvedValue([]), list: vi.fn().mockResolvedValue([]),
@@ -463,29 +435,23 @@ test("importProviderSession builds a fallback handle when a non-OpenCode provide
provider: "codex", provider: "codex",
providerHandleId: "thread-imported", providerHandleId: "thread-imported",
cwd, cwd,
labels: { source: "import" },
}, },
agentManager, agentManager,
agentStorage, agentStorage,
logger: { warn: vi.fn(), error: vi.fn() } as never, logger: { warn: vi.fn(), error: vi.fn() } as never,
}); });
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( expect(agentManager.importProviderSession).toHaveBeenCalledWith({
{ provider: "codex",
provider: "codex", providerHandleId: "thread-imported",
sessionId: "thread-imported", cwd,
nativeHandle: "thread-imported", labels: { source: "import" },
metadata: { provider: "codex", cwd }, });
},
{ cwd },
undefined,
{ labels: undefined },
);
}); });
test("importProviderSession requires cwd for missing OpenCode descriptors", async () => { test("importProviderSession requires cwd from the selected provider row", async () => {
const agentManager = { const agentManager = {} as unknown as AgentManager;
findPersistedAgent: vi.fn().mockResolvedValue(null),
} as unknown as AgentManager;
await expect( await expect(
importProviderSession({ importProviderSession({
@@ -498,7 +464,5 @@ test("importProviderSession requires cwd for missing OpenCode descriptors", asyn
agentStorage: { list: vi.fn() } as unknown as AgentStorage, agentStorage: { list: vi.fn() } as unknown as AgentStorage,
logger: { warn: vi.fn(), error: vi.fn() } as never, logger: { warn: vi.fn(), error: vi.fn() } as never,
}), }),
).rejects.toThrow( ).rejects.toThrow("Import requires cwd from the selected provider session");
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
);
}); });
@@ -1,14 +1,16 @@
import type { z } from "zod"; import type { z } from "zod";
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; 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 { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { import type {
AgentPersistenceHandle, AgentPersistenceHandle,
AgentProvider, AgentProvider,
AgentSessionConfig,
AgentTimelineItem, AgentTimelineItem,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { StructuredGenerationDaemonConfig } from "./structured-generation-providers.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."; "Generate metadata for a coding agent based on the user prompt.";
export interface NormalizedImportAgentRequest { export interface NormalizedImportAgentRequest {
provider: string; provider: AgentProvider;
providerHandleId: string; providerHandleId: string;
cwd?: string; cwd?: string;
labels?: Record<string, string>; labels?: Record<string, string>;
@@ -48,7 +50,7 @@ export class ImportSessionsRequestError extends Error {
export interface ListImportableProviderSessionsInput { export interface ListImportableProviderSessionsInput {
request: FetchRecentProviderSessionsRequestMessage; request: FetchRecentProviderSessionsRequestMessage;
agentManager: Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">; agentManager: Pick<AgentManager, "listAgents" | "listImportableSessions">;
agentStorage: Pick<AgentStorage, "list">; agentStorage: Pick<AgentStorage, "list">;
providerSnapshotManager: Pick<ProviderSnapshotManager, "getProviderLabel">; providerSnapshotManager: Pick<ProviderSnapshotManager, "getProviderLabel">;
} }
@@ -91,7 +93,7 @@ export function normalizeImportAgentRequest(
return { error: "Import requires providerId and providerHandleId" }; return { error: "Import requires providerId and providerHandleId" };
} }
return { return {
provider, provider: provider as AgentProvider,
providerHandleId, providerHandleId,
cwd: msg.cwd, cwd: msg.cwd,
labels: msg.labels, labels: msg.labels,
@@ -108,34 +110,31 @@ export async function listImportableProviderSessions(
const providerFilter = request.providers ? new Set(request.providers) : undefined; const providerFilter = request.providers ? new Set(request.providers) : undefined;
const importedHandles = await collectImportedProviderSessionHandles(agentManager, agentStorage); const importedHandles = await collectImportedProviderSessionHandles(agentManager, agentStorage);
const descriptors = await agentManager.listImportablePersistedAgents({ const sessions = await agentManager.listImportableSessions({
limit, limit,
providerFilter, providerFilter,
cwd: request.cwd, cwd: request.cwd,
}); });
let filteredAlreadyImportedCount = 0; let filteredAlreadyImportedCount = 0;
const candidates: PersistedAgentDescriptor[] = []; const candidates: ManagedImportableProviderSession[] = [];
const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null; const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null;
for (const descriptor of descriptors) { for (const session of sessions) {
if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) { if (matchesRequestCwd && !matchesRequestCwd(session.cwd)) {
continue; continue;
} }
if (sinceTimestamp !== null && descriptor.lastActivityAt.getTime() < sinceTimestamp) { if (sinceTimestamp !== null && session.lastActivityAt.getTime() < sinceTimestamp) {
continue; continue;
} }
if (isMetadataGenerationDescriptor(descriptor)) { if (isMetadataGenerationSession(session)) {
continue; continue;
} }
if (!hasUserPrompt(descriptor)) { if (
continue; importedHandles.has(toProviderSessionHandleKey(session.provider, session.providerHandleId))
} ) {
const providerHandleId =
descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId;
if (importedHandles.has(toProviderSessionHandleKey(descriptor.provider, providerHandleId))) {
filteredAlreadyImportedCount += 1; filteredAlreadyImportedCount += 1;
continue; continue;
} }
candidates.push(descriptor); candidates.push(session);
} }
const entries = candidates const entries = candidates
@@ -154,32 +153,20 @@ export async function importProviderSession(
input: ImportProviderSessionInput, input: ImportProviderSessionInput,
): Promise<ImportProviderSessionResult> { ): Promise<ImportProviderSessionResult> {
const { provider, providerHandleId, cwd, labels } = input.request; const { provider, providerHandleId, cwd, labels } = input.request;
const descriptor = await input.agentManager.findPersistedAgent(provider, providerHandleId, { if (!cwd) {
cwd, throw new Error("Import requires cwd from the selected provider session");
});
if (!descriptor && provider === "opencode" && !cwd) {
throw new Error(
"OpenCode sessions require --cwd when the session cannot be found in persisted agents",
);
} }
const handle = descriptor const handle = buildImportPersistenceHandle({ provider, providerHandleId, cwd });
? applyImportCwdOverride(descriptor.persistence, cwd)
: buildImportPersistenceHandle({ provider, providerHandleId, cwd });
const overrides = cwd ? ({ cwd } satisfies Partial<AgentSessionConfig>) : undefined;
await unarchiveAgentByHandle(input.agentStorage, input.agentManager, handle); await unarchiveAgentByHandle(input.agentStorage, input.agentManager, handle);
const snapshot = await input.agentManager.resumeAgentFromPersistence( const snapshot = await input.agentManager.importProviderSession({
handle, provider,
overrides, providerHandleId,
undefined, cwd,
{ labels,
labels, });
},
);
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id); await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
await input.agentManager.hydrateTimelineFromProvider(snapshot.id); scheduleImportedAgentMetadata({
await applyImportedAgentTitle({
snapshot, snapshot,
agentManager: input.agentManager, agentManager: input.agentManager,
workspaceGitService: input.workspaceGitService, workspaceGitService: input.workspaceGitService,
@@ -206,7 +193,8 @@ async function unarchiveAgentByHandle(
const matched = records.find( const matched = records.find(
(record) => (record) =>
record.persistence?.provider === handle.provider && record.persistence?.provider === handle.provider &&
record.persistence?.sessionId === handle.sessionId, (record.persistence.sessionId === handle.sessionId ||
record.persistence.nativeHandle === handle.nativeHandle),
); );
if (!matched) { if (!matched) {
return; return;
@@ -214,7 +202,7 @@ async function unarchiveAgentByHandle(
await unarchiveAgentState(agentStorage, agentManager, matched.id); await unarchiveAgentState(agentStorage, agentManager, matched.id);
} }
async function applyImportedAgentTitle(input: { function scheduleImportedAgentMetadata(input: {
snapshot: ManagedAgent; snapshot: ManagedAgent;
agentManager: AgentManager; agentManager: AgentManager;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">; workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
@@ -223,19 +211,16 @@ async function applyImportedAgentTitle(input: {
paseoHome?: string; paseoHome?: string;
logger: Logger; logger: Logger;
scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration; scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration;
}): Promise<void> { }): void {
const initialPrompt = getFirstUserMessageText(input.agentManager.getTimeline(input.snapshot.id)); const initialPrompt = getFirstUserMessageText(input.agentManager.getTimeline(input.snapshot.id));
if (!initialPrompt) { if (!initialPrompt) {
return; return;
} }
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({ const { explicitTitle } = resolveCreateAgentTitles({
configTitle: input.snapshot.config.title, configTitle: input.snapshot.config.title,
initialPrompt, initialPrompt,
}); });
if (!explicitTitle && provisionalTitle) {
await input.agentManager.setTitle(input.snapshot.id, provisionalTitle);
}
input.scheduleAgentMetadataGeneration({ input.scheduleAgentMetadataGeneration({
agentManager: input.agentManager, agentManager: input.agentManager,
@@ -271,36 +256,17 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
} }
function buildImportPersistenceHandle(input: { function buildImportPersistenceHandle(input: {
provider: AgentProvider; provider: string;
providerHandleId: string; providerHandleId: string;
cwd?: string; cwd: string;
}): AgentPersistenceHandle { }): AgentPersistenceHandle {
const cwd = input.cwd ?? process.cwd();
return { return {
provider: input.provider, provider: input.provider,
sessionId: input.providerHandleId, sessionId: input.providerHandleId,
nativeHandle: input.providerHandleId, nativeHandle: input.providerHandleId,
metadata: { metadata: {
provider: input.provider, provider: input.provider,
cwd, cwd: input.cwd,
},
};
}
function applyImportCwdOverride(
handle: AgentPersistenceHandle,
cwd: string | undefined,
): AgentPersistenceHandle {
if (!cwd) {
return handle;
}
return {
...handle,
metadata: {
...handle.metadata,
provider: handle.provider,
cwd,
}, },
}; };
} }
@@ -339,17 +305,9 @@ function toProviderSessionHandleKey(provider: string, providerHandleId: string):
return `${provider}\0${providerHandleId}`; return `${provider}\0${providerHandleId}`;
} }
function isMetadataGenerationDescriptor(descriptor: PersistedAgentDescriptor): boolean { function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
for (const item of descriptor.timeline) { return (
if (item.type !== "user_message") continue; input.firstPromptPreview?.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX) ?? false
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() !== "",
); );
} }
@@ -12,8 +12,6 @@ import type {
AgentStreamEvent, AgentStreamEvent,
ListModelsOptions, ListModelsOptions,
ListModesOptions, ListModesOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
ResolveAgentCreateConfigInput, ResolveAgentCreateConfigInput,
ResolveAgentCreateConfigResult, ResolveAgentCreateConfigResult,
} from "./agent-sdk-types.js"; } 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( function mapModel(
provider: AgentProvider, provider: AgentProvider,
model: AgentModelDefinition | ProviderProfileModel, model: AgentModelDefinition | ProviderProfileModel,
@@ -401,7 +385,8 @@ function wrapClientProvider(
additionalModels: ProviderProfileModel[], additionalModels: ProviderProfileModel[],
profileModelsAreAdditive: boolean, profileModelsAreAdditive: boolean,
): AgentClient { ): AgentClient {
const listPersistedAgents = inner.listPersistedAgents?.bind(inner); const listImportableSessions = inner.listImportableSessions?.bind(inner);
const importSession = inner.importSession?.bind(inner);
return { return {
provider, provider,
@@ -441,11 +426,36 @@ function wrapClientProvider(
listModes: inner.listModes?.bind(inner), listModes: inner.listModes?.bind(inner),
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
listPersistedAgents: listPersistedAgents listImportableSessions: listImportableSessions
? async (options?: ListPersistedAgentsOptions) => ? async (options) => await listImportableSessions(options)
(await listPersistedAgents(options)).map((descriptor) => : undefined,
mapPersistedAgentDescriptor(provider, descriptor), 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, : undefined,
isAvailable: () => inner.isAvailable(), isAvailable: () => inner.isAvailable(),
getDiagnostic: inner.getDiagnostic?.bind(inner), getDiagnostic: inner.getDiagnostic?.bind(inner),
@@ -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;
}
@@ -78,14 +78,17 @@ import {
type AgentStreamEvent, type AgentStreamEvent,
type AgentTimelineItem, type AgentTimelineItem,
type AgentUsage, type AgentUsage,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ListModesOptions, type ListModesOptions,
type ListModelsOptions, type ListModelsOptions,
type ListPersistedAgentsOptions,
type McpServerConfig, type McpServerConfig,
type PersistedAgentDescriptor,
type ToolCallDetail, type ToolCallDetail,
type ToolCallTimelineItem, type ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import { import {
checkProviderLaunchAvailable, checkProviderLaunchAvailable,
createProviderEnvSpec, createProviderEnvSpec,
@@ -742,16 +745,16 @@ export class ACPAgentClient implements AgentClient {
} }
} }
async listPersistedAgents( async listImportableSessions(
options?: ListPersistedAgentsOptions, options?: ListImportableSessionsOptions,
): Promise<PersistedAgentDescriptor[]> { ): Promise<ImportableProviderSession[]> {
const probe = await this.spawnProcess(PROBE_ENV); const probe = await this.spawnProcess(PROBE_ENV);
try { try {
if (!probe.initialize.agentCapabilities?.sessionCapabilities?.list) { if (!probe.initialize.agentCapabilities?.sessionCapabilities?.list) {
return []; return [];
} }
const sessions: PersistedAgentDescriptor[] = []; const sessions: ImportableProviderSession[] = [];
let cursor: string | null | undefined; let cursor: string | null | undefined;
for (;;) { for (;;) {
const page: ListSessionsResponse = await this.runACPRequest(() => const page: ListSessionsResponse = await this.runACPRequest(() =>
@@ -759,22 +762,12 @@ export class ACPAgentClient implements AgentClient {
); );
for (const session of page.sessions) { for (const session of page.sessions) {
sessions.push({ sessions.push({
provider: this.provider, providerHandleId: session.sessionId,
sessionId: session.sessionId,
cwd: session.cwd, cwd: session.cwd,
title: session.title ?? null, title: session.title ?? null,
firstPromptPreview: null,
lastPromptPreview: null,
lastActivityAt: session.updatedAt ? new Date(session.updatedAt) : new Date(0), 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; 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> { async isAvailable(): Promise<boolean> {
try { try {
await this.resolveLaunchCommand(); await this.resolveLaunchCommand();
@@ -73,11 +73,14 @@ import {
type AgentTimelineItem, type AgentTimelineItem,
type AgentUsage, type AgentUsage,
type AgentRuntimeInfo, type AgentRuntimeInfo,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ListModelsOptions, type ListModelsOptions,
type ListPersistedAgentsOptions,
type McpServerConfig, type McpServerConfig,
type PersistedAgentDescriptor,
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { import {
checkProviderLaunchAvailable, checkProviderLaunchAvailable,
createProviderEnv, createProviderEnv,
@@ -210,6 +213,7 @@ type ClaudeConversationRewindTarget =
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = { const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
supportsSessionPersistence: true, supportsSessionPersistence: true,
supportsSessionListing: true,
supportsDynamicModes: true, supportsDynamicModes: true,
supportsMcpServers: true, supportsMcpServers: true,
supportsReasoningStream: true, supportsReasoningStream: true,
@@ -1351,9 +1355,9 @@ export class ClaudeAgentClient implements AgentClient {
}); });
} }
async listPersistedAgents( async listImportableSessions(
options?: ListPersistedAgentsOptions, options?: ListImportableSessionsOptions,
): Promise<PersistedAgentDescriptor[]> { ): Promise<ImportableProviderSession[]> {
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const projectsRoot = path.join(configDir, "projects"); const projectsRoot = path.join(configDir, "projects");
if (!(await pathExists(projectsRoot))) { if (!(await pathExists(projectsRoot))) {
@@ -1365,10 +1369,19 @@ export class ClaudeAgentClient implements AgentClient {
candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)), candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)),
); );
return parsed return parsed
.filter((descriptor): descriptor is PersistedAgentDescriptor => descriptor !== null) .filter((session): session is ImportableProviderSession => session !== null)
.slice(0, limit); .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> { async isAvailable(): Promise<boolean> {
const launch = await resolveProviderLaunch({ const launch = await resolveProviderLaunch({
commandConfig: this.runtimeSettings?.command, commandConfig: this.runtimeSettings?.command,
@@ -4908,7 +4921,8 @@ interface ClaudeSessionDescriptorAccumulator {
sessionId: string | null; sessionId: string | null;
cwd: string | null; cwd: string | null;
title: string | null; title: string | null;
timeline: AgentTimelineItem[]; firstPromptPreview: string | null;
lastPromptPreview: string | null;
} }
function isFinishedAccumulator(acc: ClaudeSessionDescriptorAccumulator): boolean { function isFinishedAccumulator(acc: ClaudeSessionDescriptorAccumulator): boolean {
@@ -4941,22 +4955,18 @@ function applyClaudeSessionEntryToAccumulator(
if (!acc.title) { if (!acc.title) {
acc.title = text; acc.title = text;
} }
acc.timeline.push({ type: "user_message", text }); const preview = normalizeImportablePromptPreview(text);
acc.firstPromptPreview ??= preview;
acc.lastPromptPreview = preview;
} }
return; return;
} }
if (entry.type === "assistant" && entry.message) {
const text = extractClaudeUserText(entry.message);
if (text) {
acc.timeline.push({ type: "assistant_message", text });
}
}
} }
async function parseClaudeSessionDescriptor( async function parseClaudeSessionDescriptor(
filePath: string, filePath: string,
mtime: Date, mtime: Date,
): Promise<PersistedAgentDescriptor | null> { ): Promise<ImportableProviderSession | null> {
let content: string; let content: string;
try { try {
content = await fsPromises.readFile(filePath, "utf8"); content = await fsPromises.readFile(filePath, "utf8");
@@ -4968,7 +4978,8 @@ async function parseClaudeSessionDescriptor(
sessionId: null, sessionId: null,
cwd: null, cwd: null,
title: null, title: null,
timeline: [], firstPromptPreview: null,
lastPromptPreview: null,
}; };
for (const rawLine of content.split(/\r?\n/)) { 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) { if (!sessionId || !cwd) {
return null; return null;
} }
const persistence: AgentPersistenceHandle = {
provider: "claude",
sessionId,
nativeHandle: sessionId,
metadata: {
provider: "claude",
cwd,
},
};
return { return {
provider: "claude", providerHandleId: sessionId,
sessionId,
cwd, cwd,
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`, title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
firstPromptPreview: acc.firstPromptPreview,
lastPromptPreview: acc.lastPromptPreview,
lastActivityAt: mtime, 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 { function extractClaudeUserText(messageRaw: unknown): string | null {
const message = toObjectRecord(messageRaw); const message = toObjectRecord(messageRaw);
if (!message) { if (!message) {
@@ -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, { const client = new CodexAppServerAgentClient(logger, {
command: { command: {
mode: "replace", mode: "replace",
@@ -45,7 +45,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
process.on("uncaughtException", onUncaught); process.on("uncaughtException", onUncaught);
try { try {
await expect(client.listPersistedAgents()).rejects.toThrow(); await expect(client.listImportableSessions()).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
expect(uncaughtErrors).toHaveLength(0); expect(uncaughtErrors).toHaveLength(0);
} finally { } finally {
@@ -2671,8 +2671,8 @@ describe("Codex app-server provider", () => {
}); });
}); });
describe("Codex persisted sessions", () => { describe("Codex importable sessions", () => {
test("listPersistedAgents uses thread list metadata without hydrating thread history", async () => { test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
const allThreads = [ const allThreads = [
{ {
id: "thread-a1", id: "thread-a1",
@@ -2725,15 +2725,19 @@ describe("Codex persisted sessions", () => {
return child; 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(sessions.map((session) => session.providerHandleId).sort()).toEqual([
expect(descriptors.every((d) => d.cwd === "/workspace/project-a")).toBe(true); "thread-a1",
expect(descriptors[0]).toEqual( "thread-a2",
]);
expect(sessions.every((session) => session.cwd === "/workspace/project-a")).toBe(true);
expect(sessions[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
sessionId: "thread-a1", providerHandleId: "thread-a1",
title: "Named first A session", title: "Named first A session",
timeline: [{ type: "user_message", text: "First A session" }], firstPromptPreview: "First A session",
lastPromptPreview: "First A session",
}), }),
); );
expect(calls).toEqual([ expect(calls).toEqual([
@@ -25,10 +25,13 @@ import {
type AgentTimelineItem, type AgentTimelineItem,
type ToolCallTimelineItem, type ToolCallTimelineItem,
type AgentUsage, type AgentUsage,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ListModelsOptions, type ListModelsOptions,
type ListPersistedAgentsOptions,
type PersistedAgentDescriptor,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { homedir } from "node:os"; import { homedir } from "node:os";
@@ -172,6 +175,7 @@ function formatOutOfBandStatusMessage(text: string): string {
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = { const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
supportsSessionPersistence: true, supportsSessionPersistence: true,
supportsSessionListing: true,
supportsDynamicModes: false, supportsDynamicModes: false,
supportsMcpServers: true, supportsMcpServers: true,
supportsReasoningStream: true, supportsReasoningStream: true,
@@ -841,11 +845,6 @@ function filterCodexThreadsByCwd(
return threads.filter((thread) => typeof thread.cwd === "string" && matchesCwd(thread.cwd)); 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 { export function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
const usage = toObjectRecord(tokenUsage); const usage = toObjectRecord(tokenUsage);
if (!usage) return undefined; if (!usage) return undefined;
@@ -5487,9 +5486,9 @@ export class CodexAppServerAgentClient implements AgentClient {
return session; return session;
} }
async listPersistedAgents( async listImportableSessions(
options?: ListPersistedAgentsOptions, options?: ListImportableSessionsOptions,
): Promise<PersistedAgentDescriptor[]> { ): Promise<ImportableProviderSession[]> {
const child = await this.spawnAppServer(); const child = await this.spawnAppServer();
const client = const client =
this.deps._createCodexClient?.(child, this.logger, () => ({})) ?? 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 allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); 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 threadId = typeof thread.id === "string" ? thread.id : "";
const cwd = typeof thread.cwd === "string" ? thread.cwd : process.cwd(); const cwd = typeof thread.cwd === "string" ? thread.cwd : process.cwd();
const preview = typeof thread.preview === "string" ? thread.preview : null; const preview = typeof thread.preview === "string" ? thread.preview : null;
const title = typeof thread.name === "string" && thread.name.trim() ? thread.name : preview; const title = typeof thread.name === "string" && thread.name.trim() ? thread.name : preview;
return { return {
provider: CODEX_PROVIDER, providerHandleId: threadId,
sessionId: threadId,
cwd, cwd,
title, title,
firstPromptPreview: preview,
lastPromptPreview: preview,
lastActivityAt: new Date( lastActivityAt: new Date(
((typeof thread.updatedAt === "number" ? thread.updatedAt : undefined) ?? ((typeof thread.updatedAt === "number" ? thread.updatedAt : undefined) ??
(typeof thread.createdAt === "number" ? thread.createdAt : undefined) ?? (typeof thread.createdAt === "number" ? thread.createdAt : undefined) ??
0) * 1000, 0) * 1000,
), ),
persistence: {
provider: CODEX_PROVIDER,
sessionId: threadId,
nativeHandle: threadId,
metadata: {
provider: CODEX_PROVIDER,
cwd,
title,
threadId,
},
},
timeline: buildCodexThreadListTimeline(thread),
}; };
}); });
return descriptors;
} finally { } finally {
await client.dispose(); 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[]> { async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
// Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored. // Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored.
const child = await this.spawnAppServer(); const child = await this.spawnAppServer();
@@ -20,13 +20,15 @@ import type {
AgentSessionConfig, AgentSessionConfig,
AgentStreamEvent, AgentStreamEvent,
AgentTimelineItem, AgentTimelineItem,
ImportableProviderSession,
ImportProviderSessionContext,
ImportProviderSessionInput,
ListModesOptions, ListModesOptions,
ListModelsOptions, ListModelsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
ToolCallDetail, ToolCallDetail,
ToolCallTimelineItem, ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export const MOCK_LOAD_TEST_PROVIDER_ID = "mock"; export const MOCK_LOAD_TEST_PROVIDER_ID = "mock";
@@ -38,6 +40,7 @@ const MOCK_LOAD_TEST_INTERVAL_MS = 40;
const CAPABILITIES: AgentCapabilityFlags = { const CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
supportsSessionPersistence: true, supportsSessionPersistence: true,
supportsSessionListing: true,
supportsDynamicModes: false, supportsDynamicModes: false,
supportsMcpServers: false, supportsMcpServers: false,
supportsReasoningStream: true, supportsReasoningStream: true,
@@ -427,12 +430,19 @@ export class MockLoadTestAgentClient implements AgentClient {
return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes; return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes;
} }
async listPersistedAgents( async listImportableSessions(): Promise<ImportableProviderSession[]> {
_options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
return []; 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> { async isAvailable(): Promise<boolean> {
return true; return true;
} }
@@ -10,8 +10,6 @@ import type {
AgentSessionConfig, AgentSessionConfig,
ListModelsOptions, ListModelsOptions,
ListModesOptions, ListModesOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
export const MOCK_SLOW_PROVIDER_ID = "mock-slow"; export const MOCK_SLOW_PROVIDER_ID = "mock-slow";
@@ -48,12 +46,6 @@ export class MockSlowProviderClient implements AgentClient {
return neverResolves<AgentMode[]>(); return neverResolves<AgentMode[]>();
} }
async listPersistedAgents(
_options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
return [];
}
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
return { return {
diagnostic: diagnostic:
@@ -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 runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
const cwd = "/workspace/repo"; const cwd = "/workspace/repo";
@@ -1938,45 +1938,101 @@ describe("OpenCode persisted sessions", () => {
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); 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(sessions).toHaveLength(1);
expect(descriptors[0]).toMatchObject({ expect(sessions[0]).toMatchObject({
provider: "opencode", providerHandleId: "ses_new",
sessionId: "ses_new",
cwd, cwd,
title: "New session", title: "New session",
persistence: { firstPromptPreview: null,
provider: "opencode", lastPromptPreview: null,
sessionId: "ses_new",
nativeHandle: "ses_new",
metadata: {
modeId: "build",
model: "opencode/big-pickle",
},
},
}); });
expect(descriptors[0]?.lastActivityAt.toISOString()).toBe("1970-01-01T00:00:03.000Z"); expect(sessions[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(runtime.clientCreations).toEqual([{ baseUrl: runtime.server.url, directory: cwd }]); expect(runtime.clientCreations).toEqual([{ baseUrl: runtime.server.url, directory: cwd }]);
expect(openCodeClient.calls.experimentalSessionList).toEqual([ expect(openCodeClient.calls.experimentalSessionList).toEqual([
{ archived: true, roots: true, limit: 200 }, { archived: true, roots: true, limit: 200 },
]); ]);
expect(openCodeClient.calls.sessionMessages).toEqual([ expect(openCodeClient.calls.sessionMessages).toEqual([]);
{ sessionID: "ses_new", directory: cwd }, });
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 runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
const requestedCwd = "C:/Users/Administrator/GhostFactory"; const requestedCwd = "C:/Users/Administrator/GhostFactory";
@@ -2001,12 +2057,11 @@ describe("OpenCode persisted sessions", () => {
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); 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(sessions).toHaveLength(1);
expect(descriptors[0]).toMatchObject({ expect(sessions[0]).toMatchObject({
provider: "opencode", providerHandleId: "ses_windows",
sessionId: "ses_windows",
cwd: storedCwd, cwd: storedCwd,
title: "Windows session", title: "Windows session",
}); });
@@ -38,16 +38,19 @@ import {
type AgentStreamEvent, type AgentStreamEvent,
type AgentTimelineItem, type AgentTimelineItem,
type AgentUsage, type AgentUsage,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ResolveAgentCreateConfigInput, type ResolveAgentCreateConfigInput,
type ResolveAgentCreateConfigResult, type ResolveAgentCreateConfigResult,
type ListModelsOptions, type ListModelsOptions,
type ListModesOptions, type ListModesOptions,
type ListPersistedAgentsOptions,
type McpServerConfig, type McpServerConfig,
type PersistedAgentDescriptor,
type ToolCallDetail, type ToolCallDetail,
type ToolCallTimelineItem, type ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import { import {
isDefaultAgentCreateConfigUnattended, isDefaultAgentCreateConfigUnattended,
resolveDefaultAgentCreateConfig, resolveDefaultAgentCreateConfig,
@@ -84,6 +87,7 @@ import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
supportsSessionPersistence: true, supportsSessionPersistence: true,
supportsSessionListing: true,
supportsDynamicModes: true, supportsDynamicModes: true,
supportsMcpServers: true, supportsMcpServers: true,
supportsReasoningStream: true, supportsReasoningStream: true,
@@ -907,10 +911,10 @@ function buildOpenCodeUserTimelineText(prompt: AgentPromptInput): string {
.join("\n"); .join("\n");
} }
async function collectOpenCodePersistedAgentsFromSdk( async function collectOpenCodeImportableSessionsFromSdk(
client: Pick<OpencodeClient, "experimental" | "session">, client: Pick<OpencodeClient, "experimental">,
options?: ListPersistedAgentsOptions, options?: ListImportableSessionsOptions,
): Promise<PersistedAgentDescriptor[]> { ): Promise<ImportableProviderSession[]> {
const limit = options?.limit ?? OPENCODE_PERSISTED_SESSION_LIMIT; const limit = options?.limit ?? OPENCODE_PERSISTED_SESSION_LIMIT;
const sessionListLimit = options?.cwd ? Math.max(limit, OPENCODE_PERSISTED_SESSION_LIMIT) : limit; const sessionListLimit = options?.cwd ? Math.max(limit, OPENCODE_PERSISTED_SESSION_LIMIT) : limit;
const response = await client.experimental.session.list({ 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)}`); 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 matchesCwd = options?.cwd ? createPathEquivalenceMatcher(options.cwd) : null;
const candidates = sessions return (response.data ?? [])
.filter((session) => !matchesCwd || matchesCwd(session.directory)) .filter((session) => !matchesCwd || matchesCwd(session.directory))
.sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left)) .sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left))
.slice(0, limit); .slice(0, limit)
.map((session) => ({
return await Promise.all( providerHandleId: session.id,
candidates.map((session) => buildOpenCodePersistedAgentDescriptor(client, session)), cwd: session.directory,
); title: normalizeOpenCodeSessionTitle(session.title),
} firstPromptPreview: null,
lastPromptPreview: null,
async function buildOpenCodePersistedAgentDescriptor( lastActivityAt: new Date(getOpenCodeSessionTimestamp(session)),
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,
};
} }
function normalizeOpenCodeSessionTitle(title: string | null | undefined): string | null { function normalizeOpenCodeSessionTitle(title: string | null | undefined): string | null {
@@ -1498,9 +1475,9 @@ export class OpenCodeAgentClient implements AgentClient {
return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))]; return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))];
} }
async listPersistedAgents( async listImportableSessions(
options?: ListPersistedAgentsOptions, options?: ListImportableSessionsOptions,
): Promise<PersistedAgentDescriptor[]> { ): Promise<ImportableProviderSession[]> {
const acquisition = await this.runtime.acquireServer({ force: false }); const acquisition = await this.runtime.acquireServer({ force: false });
const { url } = acquisition.server; const { url } = acquisition.server;
const client = this.runtime.createClient({ const client = this.runtime.createClient({
@@ -1509,7 +1486,43 @@ export class OpenCodeAgentClient implements AgentClient {
}); });
try { 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 { } finally {
acquisition.release(); acquisition.release();
} }
@@ -66,6 +66,7 @@ export class TestOpenCodeClient {
sessionCommand: [] as unknown[], sessionCommand: [] as unknown[],
sessionCreate: [] as unknown[], sessionCreate: [] as unknown[],
sessionDelete: [] as unknown[], sessionDelete: [] as unknown[],
sessionGet: [] as unknown[],
sessionMessages: [] as unknown[], sessionMessages: [] as unknown[],
sessionPromptAsync: [] as unknown[], sessionPromptAsync: [] as unknown[],
sessionSummarize: [] as unknown[], sessionSummarize: [] as unknown[],
@@ -89,6 +90,9 @@ export class TestOpenCodeClient {
sessionCommandResponse: OpenCodeResponse = {}; sessionCommandResponse: OpenCodeResponse = {};
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } }; sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
sessionDeleteResponse: OpenCodeResponse = {}; sessionDeleteResponse: OpenCodeResponse = {};
sessionGetResponse: OpenCodeResponse = {
data: { id: "session-1", directory: "/workspace/repo", title: null },
};
sessionMessagesResponse: OpenCodeResponse = { data: [] }; sessionMessagesResponse: OpenCodeResponse = { data: [] };
sessionPromptAsyncEvents: unknown[] = [idleEvent()]; sessionPromptAsyncEvents: unknown[] = [idleEvent()];
sessionPromptAsyncResponse: OpenCodeResponse = {}; sessionPromptAsyncResponse: OpenCodeResponse = {};
@@ -196,6 +200,10 @@ export class TestOpenCodeClient {
this.calls.sessionDelete.push(parameters); this.calls.sessionDelete.push(parameters);
return this.sessionDeleteResponse; return this.sessionDeleteResponse;
}, },
get: async (parameters: unknown) => {
this.calls.sessionGet.push(parameters);
return this.sessionGetResponse;
},
messages: async (parameters: unknown) => { messages: async (parameters: unknown) => {
this.calls.sessionMessages.push(parameters); this.calls.sessionMessages.push(parameters);
return this.sessionMessagesResponse; return this.sessionMessagesResponse;
@@ -720,23 +720,14 @@ describe("PiRpcAgentClient", () => {
providerParams: { sessionDir: sessionsDir }, providerParams: { sessionDir: sessionsDir },
}); });
await expect(client.listPersistedAgents({ cwd })).resolves.toEqual([ await expect(client.listImportableSessions({ cwd })).resolves.toEqual([
{ {
provider: "pi", providerHandleId: sessionFile,
sessionId: "pi-session-jsonl",
cwd, cwd,
title: "Imported Pi session", title: "Imported Pi session",
firstPromptPreview: "first prompt",
lastPromptPreview: "last prompt",
lastActivityAt: new Date("2026-01-01T00:00:03.000Z"), 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", providerHandleId: sessionFile,
sessionId: "pi-default-session",
cwd, cwd,
title: "default dir prompt", title: "default dir prompt",
persistence: { firstPromptPreview: "default dir prompt",
provider: "pi", lastPromptPreview: "default dir prompt",
sessionId: "pi-default-session",
nativeHandle: sessionFile,
metadata: { provider: "pi", cwd },
},
}, },
]); ]);
}); });
@@ -26,11 +26,14 @@ import {
type AgentSlashCommandKind, type AgentSlashCommandKind,
type AgentStreamEvent, type AgentStreamEvent,
type AgentUsage, type AgentUsage,
type ListPersistedAgentsOptions, type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ListModesOptions, type ListModesOptions,
type ListModelsOptions, type ListModelsOptions,
type PersistedAgentDescriptor,
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderTurn } from "../provider-runner.js"; import { runProviderTurn } from "../provider-runner.js";
import { import {
checkProviderLaunchAvailable, checkProviderLaunchAvailable,
@@ -54,7 +57,7 @@ import {
} from "./history-mapper.js"; } from "./history-mapper.js";
import { PiCliRuntime } from "./cli-runtime.js"; import { PiCliRuntime } from "./cli-runtime.js";
import { revertPiConversation } from "./rewind.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 { PiRuntime, PiRuntimeSession } from "./runtime.js";
import type { import type {
PiAgentSessionEvent, PiAgentSessionEvent,
@@ -122,6 +125,7 @@ function mapPiCommandKind(source: PiRpcSlashCommand["source"]): AgentSlashComman
const PI_CAPABILITIES: AgentCapabilityFlags = { const PI_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
supportsSessionPersistence: true, supportsSessionPersistence: true,
supportsSessionListing: true,
supportsDynamicModes: true, supportsDynamicModes: true,
supportsMcpServers: false, supportsMcpServers: false,
supportsReasoningStream: true, supportsReasoningStream: true,
@@ -1969,17 +1973,25 @@ export class PiRpcAgentClient implements AgentClient {
return []; return [];
} }
async listPersistedAgents( async listImportableSessions(
options?: ListPersistedAgentsOptions, options?: ListImportableSessionsOptions,
): Promise<PersistedAgentDescriptor[]> { ): Promise<ImportableProviderSession[]> {
return await listPiPersistedAgents({ return await listPiImportableSessions({
...options, ...options,
provider: PI_PROVIDER,
sessionDir: this.providerParams.sessionDir, sessionDir: this.providerParams.sessionDir,
runtimeSettings: this.runtimeSettings, 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> { async isAvailable(): Promise<boolean> {
const launch = await this.resolvePiLaunch(); const launch = await this.resolvePiLaunch();
const availability = await checkProviderLaunchAvailable(launch); const availability = await checkProviderLaunchAvailable(launch);
@@ -3,15 +3,12 @@ import { homedir } from "node:os";
import path from "node:path"; import path from "node:path";
import type { import type {
AgentPersistenceHandle, ImportableProviderSession,
AgentTimelineItem, ListImportableSessionsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js"; import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const PI_PROVIDER = "pi";
const PI_CONFIG_DIR_NAME = ".pi"; const PI_CONFIG_DIR_NAME = ".pi";
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"; const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_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 TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000; const FULL_SCAN_LINE_LIMIT = 2_000;
interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions { interface PiSessionDescriptorOptions extends ListImportableSessionsOptions {
provider?: string;
sessionDir?: string; sessionDir?: string;
runtimeSettings?: ProviderRuntimeSettings; runtimeSettings?: ProviderRuntimeSettings;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
@@ -39,24 +35,23 @@ interface PiSessionTail {
lastUserMessage: string | null; lastUserMessage: string | null;
} }
export async function listPiPersistedAgents( export async function listPiImportableSessions(
options: PiSessionDescriptorOptions = {}, options: PiSessionDescriptorOptions = {},
): Promise<PersistedAgentDescriptor[]> { ): Promise<ImportableProviderSession[]> {
const provider = options.provider ?? PI_PROVIDER;
const sessionsDir = await resolvePiSessionsDir(options); const sessionsDir = await resolvePiSessionsDir(options);
const files = await walkJsonlFiles(sessionsDir); const files = await walkJsonlFiles(sessionsDir);
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null; const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
const limit = options.limit ?? 20; const limit = options.limit ?? 20;
const descriptors: PersistedAgentDescriptor[] = []; const sessions: ImportableProviderSession[] = [];
for (const file of files) { for (const file of files) {
const descriptor = await readPiSessionDescriptor(file, provider); const session = await readPiImportableSession(file);
if (!descriptor) continue; if (!session) continue;
if (matchesCwd && !matchesCwd(descriptor.cwd)) continue; if (matchesCwd && !matchesCwd(session.cwd)) continue;
descriptors.push(descriptor); sessions.push(session);
} }
return descriptors return sessions
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()) .sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
.slice(0, limit); .slice(0, limit);
} }
@@ -157,10 +152,9 @@ async function walkJsonlFiles(root: string): Promise<string[]> {
return files.flat(); return files.flat();
} }
async function readPiSessionDescriptor( async function readPiImportableSession(
filePath: string, filePath: string,
provider: string, ): Promise<ImportableProviderSession | null> {
): Promise<PersistedAgentDescriptor | null> {
const firstLine = await readFirstLine(filePath); const firstLine = await readFirstLine(filePath);
if (!firstLine) return null; if (!firstLine) return null;
const header = parseSessionHeader(firstLine); const header = parseSessionHeader(firstLine);
@@ -172,29 +166,15 @@ async function readPiSessionDescriptor(
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage; const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
const lastActivityAt = const lastActivityAt =
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0); 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 { return {
provider, providerHandleId: filePath,
sessionId: header.sessionId,
cwd: header.cwd, cwd: header.cwd,
title, title,
firstPromptPreview: normalizePromptPreview(headInfo.firstUserMessage),
lastPromptPreview: normalizePromptPreview(
tailInfo.lastUserMessage ?? headInfo.firstUserMessage,
),
lastActivityAt, lastActivityAt,
persistence,
timeline,
}; };
} }
@@ -326,20 +306,6 @@ async function scanSessionHead(filePath: string): Promise<{
return { title, firstUserMessage }; 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 { function parseJsonRecord(line: string): Record<string, unknown> | null {
if (!line) return null; if (!line) return null;
try { try {
@@ -358,6 +324,12 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : 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 { function parseDate(value: unknown): Date | null {
if (typeof value !== "string" && typeof value !== "number") { if (typeof value !== "string" && typeof value !== "number") {
return null; return null;
@@ -5,7 +5,7 @@ import path from "node:path";
import pino from "pino"; import pino from "pino";
import { beforeAll, beforeEach, describe, expect, test } from "vitest"; 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 { OpenCodeServerManager } from "../agent/providers/opencode/server-manager.js";
import { DaemonClient } from "../test-utils/daemon-client.js"; import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -41,11 +41,16 @@ async function withConnectedOpenCodeDaemon(
async function deletePersistedSessions( async function deletePersistedSessions(
provider: AgentClient, provider: AgentClient,
sessions: ReadonlyArray<PersistedAgentDescriptor>, sessions: ReadonlyArray<ImportableProviderSession>,
): Promise<void> { ): Promise<void> {
await Promise.all( await Promise.all(
sessions.map(async (session) => { 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(); await resumed.close();
}), }),
); );
@@ -68,10 +73,10 @@ describe("daemon E2E (real opencode) - draft feature discovery", () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const provider = createRealProviderClient("opencode", logger); const provider = createRealProviderClient("opencode", logger);
const cwd = tmpCwd(); const cwd = tmpCwd();
let after: PersistedAgentDescriptor[] = []; let after: ImportableProviderSession[] = [];
try { try {
expect(await provider.listPersistedAgents?.({ cwd })).toEqual([]); expect(await provider.listImportableSessions?.({ cwd })).toEqual([]);
await withConnectedOpenCodeDaemon(provider, async ({ client }) => { await withConnectedOpenCodeDaemon(provider, async ({ client }) => {
const response = await client.listProviderFeatures({ const response = await client.listProviderFeatures({
@@ -84,7 +89,7 @@ describe("daemon E2E (real opencode) - draft feature discovery", () => {
expect(response.features ?? []).toEqual([]); expect(response.features ?? []).toEqual([]);
}); });
after = (await provider.listPersistedAgents?.({ cwd })) ?? []; after = (await provider.listImportableSessions?.({ cwd })) ?? [];
} finally { } finally {
await deletePersistedSessions(provider, after); await deletePersistedSessions(provider, after);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
@@ -98,10 +103,10 @@ describe("daemon E2E (real opencode) - draft feature discovery", () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const provider = createRealProviderClient("opencode", logger); const provider = createRealProviderClient("opencode", logger);
const cwd = tmpCwd(); const cwd = tmpCwd();
let after: PersistedAgentDescriptor[] = []; let after: ImportableProviderSession[] = [];
try { try {
expect(await provider.listPersistedAgents?.({ cwd })).toEqual([]); expect(await provider.listImportableSessions?.({ cwd })).toEqual([]);
await withConnectedOpenCodeDaemon(provider, async ({ client }) => { await withConnectedOpenCodeDaemon(provider, async ({ client }) => {
const response = await client.listCommands("draft-opencode-agent", { 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); expect(response.commands.length).toBeGreaterThan(0);
}); });
after = (await provider.listPersistedAgents?.({ cwd })) ?? []; after = (await provider.listImportableSessions?.({ cwd })) ?? [];
} finally { } finally {
await deletePersistedSessions(provider, after); await deletePersistedSessions(provider, after);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
-1
View File
@@ -3305,7 +3305,6 @@ export class Session {
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
await this.registerWorkspaceForImportedAgent(snapshot.cwd); await this.registerWorkspaceForImportedAgent(snapshot.cwd);
await this.forwardAgentUpdate(snapshot);
const agentPayload = await this.buildAgentPayload(snapshot); const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({ this.emit({
type: "status", type: "status",
@@ -17,7 +17,6 @@ import type {
AgentSession, AgentSession,
AgentSessionConfig, AgentSessionConfig,
AgentStreamEvent, AgentStreamEvent,
PersistedAgentDescriptor,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
@@ -63,12 +62,8 @@ interface SessionTestAccess {
}; };
agentManager: { agentManager: {
listAgents(): unknown[]; listAgents(): unknown[];
listImportablePersistedAgents(options?: unknown): Promise<PersistedAgentDescriptor[]>; listImportableSessions(options?: unknown): Promise<unknown[]>;
findPersistedAgent( importProviderSession(input: unknown): Promise<unknown>;
provider: string,
providerHandleId: string,
options?: unknown,
): Promise<PersistedAgentDescriptor | null>;
resumeAgentFromPersistence( resumeAgentFromPersistence(
handle: unknown, handle: unknown,
overrides?: unknown, overrides?: unknown,
@@ -274,7 +269,7 @@ function makeManagedAgent(input: {
}; };
} }
function makePersistedProviderSession(input: { function makeImportableProviderSession(input: {
provider: string; provider: string;
sessionId: string; sessionId: string;
nativeHandle?: string; nativeHandle?: string;
@@ -282,19 +277,23 @@ function makePersistedProviderSession(input: {
title?: string | null; title?: string | null;
lastActivityAt: string; lastActivityAt: string;
firstPrompt?: string; firstPrompt?: string;
}): PersistedAgentDescriptor { }): {
provider: string;
providerHandleId: string;
cwd: string;
title: string | null;
firstPromptPreview: string | null;
lastPromptPreview: string | null;
lastActivityAt: Date;
} {
return { return {
provider: input.provider, provider: input.provider,
sessionId: input.sessionId, providerHandleId: input.nativeHandle ?? input.sessionId,
cwd: input.cwd, cwd: input.cwd,
title: input.title ?? null, title: input.title ?? null,
firstPromptPreview: input.firstPrompt ?? null,
lastPromptPreview: input.firstPrompt ?? null,
lastActivityAt: new Date(input.lastActivityAt), 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[] = [ const importableSessions = [
makePersistedProviderSession({ makeImportableProviderSession({
provider: "codex", provider: "codex",
sessionId: "outside-filter", sessionId: "outside-filter",
nativeHandle: "outside-filter-handle", nativeHandle: "outside-filter-handle",
@@ -2136,7 +2135,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions
title: "Outside filter", title: "Outside filter",
lastActivityAt: "2026-04-30T12:05:00.000Z", lastActivityAt: "2026-04-30T12:05:00.000Z",
}), }),
makePersistedProviderSession({ makeImportableProviderSession({
provider: "codex", provider: "codex",
sessionId: "stored-session", sessionId: "stored-session",
nativeHandle: "stored-handle", nativeHandle: "stored-handle",
@@ -2145,14 +2144,14 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions
lastActivityAt: "2026-04-30T12:04:00.000Z", lastActivityAt: "2026-04-30T12:04:00.000Z",
firstPrompt: "stored prompt", firstPrompt: "stored prompt",
}), }),
makePersistedProviderSession({ makeImportableProviderSession({
provider: "claude", provider: "claude",
sessionId: "wrong-provider", sessionId: "wrong-provider",
cwd: "/tmp/recent", cwd: "/tmp/recent",
title: "Wrong provider", title: "Wrong provider",
lastActivityAt: "2026-04-30T12:03:00.000Z", lastActivityAt: "2026-04-30T12:03:00.000Z",
}), }),
makePersistedProviderSession({ makeImportableProviderSession({
provider: "codex", provider: "codex",
sessionId: "older-session", sessionId: "older-session",
nativeHandle: "older-handle", nativeHandle: "older-handle",
@@ -2160,7 +2159,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions
title: "Older than since", title: "Older than since",
lastActivityAt: "2026-04-29T23:59:59.000Z", lastActivityAt: "2026-04-29T23:59:59.000Z",
}), }),
makePersistedProviderSession({ makeImportableProviderSession({
provider: "codex", provider: "codex",
sessionId: "newer-session", sessionId: "newer-session",
nativeHandle: "newer-handle", nativeHandle: "newer-handle",
@@ -2169,7 +2168,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions
lastActivityAt: "2026-04-30T12:02:00.000Z", lastActivityAt: "2026-04-30T12:02:00.000Z",
firstPrompt: "newer prompt", firstPrompt: "newer prompt",
}), }),
makePersistedProviderSession({ makeImportableProviderSession({
provider: "codex", provider: "codex",
sessionId: "second-session", sessionId: "second-session",
nativeHandle: "second-handle", nativeHandle: "second-handle",
@@ -2178,7 +2177,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions
lastActivityAt: "2026-04-30T12:00:00.000Z", lastActivityAt: "2026-04-30T12:00:00.000Z",
firstPrompt: "second prompt", firstPrompt: "second prompt",
}), }),
makePersistedProviderSession({ makeImportableProviderSession({
provider: "codex", provider: "codex",
sessionId: "third-session", sessionId: "third-session",
nativeHandle: "third-handle", nativeHandle: "third-handle",
@@ -2187,7 +2186,7 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions
lastActivityAt: "2026-04-30T11:59:00.000Z", lastActivityAt: "2026-04-30T11:59:00.000Z",
firstPrompt: "third prompt", firstPrompt: "third prompt",
}), }),
makePersistedProviderSession({ makeImportableProviderSession({
provider: "codex", provider: "codex",
sessionId: "live-session", sessionId: "live-session",
nativeHandle: "live-handle", 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 // The real AgentManager filters by providerFilter at the fan-out level
// (Phase 1). Mirror that here so the mock matches the contract. // (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) const providerFilter = (options as { providerFilter?: Set<string> } | undefined)
?.providerFilter; ?.providerFilter;
if (!providerFilter) { return providerFilter
return persistedDescriptors; ? importableSessions.filter((entry) => providerFilter.has(entry.provider))
} : importableSessions;
return persistedDescriptors.filter((d) => providerFilter.has(d.provider));
}; };
session.agentStorage.list = async () => [ 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.emit = (message) => emitted.push(message as { type: string; payload: unknown });
session.agentManager.listAgents = () => []; session.agentManager.listAgents = () => [];
session.agentStorage.list = async () => []; session.agentStorage.list = async () => [];
session.agentManager.listImportablePersistedAgents = async (options?: unknown) => { session.agentManager.listImportableSessions = async (options?: unknown) => {
capturedOptions = options as { providerFilter?: Set<string>; limit?: number }; capturedOptions = options as { providerFilter?: Set<string>; limit?: number };
return []; return [];
}; };
@@ -2316,16 +2314,16 @@ test("fetch_recent_provider_sessions_request reports filteredAlreadyImportedCoun
}, },
]; ];
session.agentStorage.list = async () => []; session.agentStorage.list = async () => [];
session.agentManager.listImportablePersistedAgents = async () => [ session.agentManager.listImportableSessions = async () => [
makePersistedProviderSession({ {
provider: "codex", provider: "codex",
sessionId: "live-session", providerHandleId: "live-handle",
nativeHandle: "live-handle",
cwd: "/tmp/recent", cwd: "/tmp/recent",
title: "Already live", title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z", firstPromptPreview: "live prompt",
firstPrompt: "live prompt", lastPromptPreview: "live prompt",
}), lastActivityAt: new Date("2026-04-30T12:01:00.000Z"),
},
]; ];
await session.handleMessage({ 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", updatedAt: "2026-05-21T00:00:00.000Z",
}); });
session.agentManager.listAgents = () => [managed]; session.agentManager.listAgents = () => [managed];
session.agentManager.findPersistedAgent = async () => null; session.agentManager.importProviderSession = async () => managed;
session.agentManager.resumeAgentFromPersistence = async () => managed;
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
session.agentManager.getTimeline = () => []; session.agentManager.getTimeline = () => [];
session.agentManager.setTitle = async () => undefined; session.agentManager.setTitle = async () => undefined;
session.agentStorage.list = async () => []; session.agentStorage.list = async () => [];