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