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:
+9
-1
@@ -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");
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+2
-2
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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 });
|
||||||
|
|||||||
@@ -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 () => [];
|
||||||
|
|||||||
Reference in New Issue
Block a user