diff --git a/CLAUDE.md b/CLAUDE.md index bf39da886..cfbd4a042 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,12 +75,19 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir - **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script: - `npm run lint -- packages/app/src/components/message.tsx` - `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx` -- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons: - - New fields: always `.optional()` with a sensible default or `.transform()` fallback. - - Never change a field from optional to required. - - Never remove a field — deprecate it (keep accepting it, stop sending it). - - Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null). - - Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?" +- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts: + - **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client. + - New fields: `.optional()` with a sensible default or `.transform()` fallback. + - Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null). + - Removed fields stay accepted (we stop sending them, not stop reading them). + - Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?" + - **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it. + - **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature. + - **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape. + - **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site. + - Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal. + +- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable. ## Platform gating diff --git a/packages/app/src/screens/workspace/workspace-import-sheet.test.tsx b/packages/app/src/screens/workspace/workspace-import-sheet.test.tsx new file mode 100644 index 000000000..9ce5dc958 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-import-sheet.test.tsx @@ -0,0 +1,685 @@ +/** + * @vitest-environment jsdom + */ +import React, { type ReactNode } from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client"; +import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet"; + +const { theme } = vi.hoisted(() => ({ + theme: { + spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16 }, + borderWidth: { 1: 1 }, + borderRadius: { md: 6, full: 9999 }, + fontSize: { xs: 11, sm: 13, base: 15 }, + fontWeight: { normal: "400", medium: "500", semibold: "600" }, + colors: { + foreground: "#fff", + foregroundMuted: "#aaa", + surface1: "#111", + surface2: "#222", + surface3: "#333", + border: "#444", + borderAccent: "#555", + }, + }, +})); + +vi.hoisted(() => { + (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; +}); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), + withUnistyles: + (Component: React.ComponentType>) => + ({ + uniProps, + ...rest + }: { + uniProps?: (theme: unknown) => Record; + } & Record) => { + const themed = uniProps ? uniProps(theme) : {}; + return React.createElement(Component, { ...rest, ...themed }); + }, +})); + +vi.mock("@/constants/layout", () => ({ + useIsCompactFormFactor: () => false, +})); + +vi.mock("@/components/provider-icons", () => ({ + getProviderIcon: () => () => null, +})); + +vi.mock("@/components/adaptive-modal-sheet", () => ({ + AdaptiveModalSheet: ({ + visible, + title, + children, + testID, + }: { + visible: boolean; + title: string; + children: ReactNode; + testID?: string; + }) => + visible ? ( +
+

{title}

+ {children} +
+ ) : null, +})); + +vi.mock("react-native", async () => { + const actual = await vi.importActual>("react-native"); + return actual; +}); + +const mockSnapshot = vi.hoisted(() => ({ + current: { + entries: undefined as ProviderSnapshotEntry[] | undefined, + supportsSnapshot: false, + }, +})); + +vi.mock("@/hooks/use-providers-snapshot", () => ({ + useProvidersSnapshot: () => ({ + entries: mockSnapshot.current.entries, + isLoading: false, + isFetching: false, + isRefreshing: false, + error: null, + supportsSnapshot: mockSnapshot.current.supportsSnapshot, + refresh: vi.fn(), + refetchIfStale: vi.fn(), + }), +})); + +interface RenderOptions { + visible?: boolean; + onClose?: () => void; + onImportedAgent?: (agentId: string) => void; + snapshot?: { + entries?: ProviderSnapshotEntry[]; + supportsSnapshot?: boolean; + }; +} + +function renderSheet( + client: Pick, + options?: RenderOptions, +) { + mockSnapshot.current = { + entries: options?.snapshot?.entries, + supportsSnapshot: options?.snapshot?.supportsSnapshot ?? false, + }; + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + , + ); +} + +function createRecentSessionsClient( + fetchRecentProviderSessions: Pick< + DaemonClient, + "fetchRecentProviderSessions" + >["fetchRecentProviderSessions"], + importAgent: Pick["importAgent"], +): Pick { + return { fetchRecentProviderSessions, importAgent }; +} + +function createImportedAgentSnapshot(id: string): Awaited> { + return { + id, + provider: "custom-provider", + cwd: "/repo/paseo", + model: null, + createdAt: "2026-04-30T10:00:00.000Z", + updatedAt: "2026-04-30T10:00:00.000Z", + lastUserMessageAt: "2026-04-30T10:00:00.000Z", + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }; +} + +function createProviderSessionEntry( + overrides?: Partial, +): FetchRecentProviderSessionEntry { + return { + providerId: "custom-provider", + providerLabel: "Custom Agent", + providerHandleId: "provider-thread-1", + cwd: "/repo/paseo", + title: "Import me", + firstPromptPreview: "Import this external provider session", + lastPromptPreview: "Import this external provider session", + lastActivityAt: "2026-04-30T10:00:00.000Z", + ...overrides, + }; +} + +const PROVIDER_LABELS: Record = { + claude: "Claude Code", + codex: "Codex", + opencode: "OpenCode", +}; + +function createSnapshotEntry( + provider: string, + overrides?: Partial, +): ProviderSnapshotEntry { + return { + provider, + status: "ready", + enabled: true, + label: PROVIDER_LABELS[provider] ?? provider, + ...overrides, + }; +} + +describe("WorkspaceImportSheet", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("shows an update-host message when the daemon does not support provider snapshots", async () => { + const fetchRecentProviderSessions = vi.fn(); + const importAgent = vi.fn(); + + renderSheet({ fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >); + + await screen.findByText("Update the host to import sessions."); + expect(fetchRecentProviderSessions).not.toHaveBeenCalled(); + }); + + it("shows a loading state while provider snapshot is loading", async () => { + const fetchRecentProviderSessions = vi.fn( + () => new Promise>>(() => {}), + ); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { supportsSnapshot: true, entries: undefined }, + }, + ); + + await screen.findByText("Loading recent sessions..."); + expect(fetchRecentProviderSessions).not.toHaveBeenCalled(); + }); + + it("shows an empty state when there are no recent provider sessions to import", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await screen.findByText("No recent sessions to import."); + }); + + it("shows the all-already-imported empty state when filteredAlreadyImportedCount is positive", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [], + filteredAlreadyImportedCount: 3, + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await screen.findByText("All recent sessions are already imported."); + expect(screen.queryByText("No recent sessions to import.")).toBeNull(); + }); + + it("shows a fetch error state when recent provider sessions cannot be loaded", async () => { + const fetchRecentProviderSessions = vi.fn(async () => { + throw new Error("recent sessions unavailable"); + }); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await screen.findByText("Could not load recent sessions."); + }); + + it("loads recent provider sessions for the workspace and renders descriptor-owned labels", async () => { + vi.setSystemTime(new Date("2026-04-30T12:00:00.000Z")); + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerLabel: "Claude Code", + title: null, + firstPromptPreview: "Implement the importer sheet", + lastPromptPreview: "Make the rows readable and provider opaque", + }), + ], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + cwd: "/repo/paseo", + providers: ["claude"], + limit: 15, + }); + }); + + await screen.findByText("Claude Code"); + screen.getByText("2h ago"); + screen.getByText("Implement the importer sheet"); + screen.getByText("Make the rows readable and provider opaque"); + }); + + it("keeps cached rows visible and revalidates when reopened", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerLabel: "Claude Code", + title: "Cached importable session", + }), + ], + })); + const importAgent = vi.fn(); + const client = createRecentSessionsClient(fetchRecentProviderSessions, importAgent); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + mockSnapshot.current = { + entries: [createSnapshotEntry("claude")], + supportsSnapshot: true, + }; + + function TestSheet({ visible }: { visible: boolean }) { + return ( + + + + ); + } + + const { rerender } = render(); + + await screen.findByText("Cached importable session"); + expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1); + + rerender(); + fetchRecentProviderSessions.mockClear(); + rerender(); + + await screen.findByText("Cached importable session"); + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + cwd: "/repo/paseo", + providers: ["claude"], + limit: 15, + }); + }); + }); + + 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" })], + })); + const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported")); + const onClose = vi.fn(); + const onImportedAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + onClose, + onImportedAgent, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1")); + + await waitFor(() => { + expect(importAgent).toHaveBeenCalledWith({ + providerId: "claude", + providerHandleId: "provider-thread-1", + cwd: "/repo/paseo", + }); + }); + expect(onImportedAgent).toHaveBeenCalledWith("agent-imported"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("shows an import error state without closing when selected session import fails", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [createProviderSessionEntry({ providerId: "claude", providerLabel: "Claude Code" })], + })); + const importAgent = vi.fn(async () => { + throw new Error("import unavailable"); + }); + const onClose = vi.fn(); + const onImportedAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + onClose, + onImportedAgent, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1")); + + await screen.findByText("Could not import selected session."); + expect(importAgent).toHaveBeenCalledWith({ + providerId: "claude", + providerHandleId: "provider-thread-1", + cwd: "/repo/paseo", + }); + expect(onImportedAgent).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("fans out one request per enabled importable provider when snapshot is supported", async () => { + const fetchRecentProviderSessions = vi.fn( + async (options: { providers?: string[] } | undefined) => ({ + requestId: `recent-${options?.providers?.[0] ?? "all"}`, + entries: [ + createProviderSessionEntry({ + providerId: options?.providers?.[0] ?? "custom-provider", + providerLabel: options?.providers?.[0] ?? "Custom", + providerHandleId: `${options?.providers?.[0] ?? "custom-provider"}-thread`, + title: `Session ${options?.providers?.[0] ?? "all"}`, + lastActivityAt: "2026-04-30T10:00:00.000Z", + }), + ], + }), + ); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { + supportsSnapshot: true, + entries: [ + createSnapshotEntry("claude"), + createSnapshotEntry("codex"), + createSnapshotEntry("opencode", { enabled: false }), + createSnapshotEntry("z-ai"), + ], + }, + }, + ); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + cwd: "/repo/paseo", + providers: ["claude"], + limit: 15, + }); + }); + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + cwd: "/repo/paseo", + providers: ["codex"], + limit: 15, + }); + expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith( + expect.objectContaining({ providers: ["opencode"] }), + ); + expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith( + expect.objectContaining({ providers: ["z-ai"] }), + ); + + await screen.findByText("Session claude"); + await screen.findByText("Session codex"); + }); + + it("shows partial-failure note when one provider request fails but others succeed", async () => { + const fetchRecentProviderSessions = vi.fn( + async (options: { providers?: string[] } | undefined) => { + const provider = options?.providers?.[0]; + if (provider === "claude") { + throw new Error("claude offline"); + } + return { + requestId: `recent-${provider ?? "all"}`, + entries: [ + createProviderSessionEntry({ + providerId: provider ?? "custom-provider", + providerHandleId: `${provider}-thread`, + providerLabel: provider ?? "Custom", + title: `Session ${provider}`, + }), + ], + }; + }, + ); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { + supportsSnapshot: true, + entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")], + }, + }, + ); + + await screen.findByText("Session codex"); + await screen.findByText("Could not load sessions for Claude Code."); + }); + + it("filters the merged list when a provider badge is selected and restores it on All", async () => { + const fetchRecentProviderSessions = vi.fn( + async (options: { providers?: string[] } | undefined) => { + const provider = options?.providers?.[0] ?? "claude"; + return { + requestId: `recent-${provider}`, + entries: [ + createProviderSessionEntry({ + providerId: provider, + providerLabel: provider === "claude" ? "Claude Code" : "Codex", + providerHandleId: `${provider}-thread`, + title: `Session ${provider}`, + lastActivityAt: + provider === "claude" ? "2026-04-30T09:00:00.000Z" : "2026-04-30T10:00:00.000Z", + }), + ], + }; + }, + ); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { + supportsSnapshot: true, + entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")], + }, + }, + ); + + await screen.findByText("Session claude"); + await screen.findByText("Session codex"); + + fireEvent.click(screen.getByTestId("workspace-import-filter-codex")); + + screen.getByText("Session codex"); + expect(screen.queryByText("Session claude")).toBeNull(); + + fireEvent.click(screen.getByTestId("workspace-import-filter-all")); + + screen.getByText("Session claude"); + screen.getByText("Session codex"); + }); + + it("does not render filter badges when only one importable provider is enabled", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-codex", + entries: [createProviderSessionEntry({ providerId: "codex", providerLabel: "Codex" })], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { + supportsSnapshot: true, + entries: [ + createSnapshotEntry("codex"), + createSnapshotEntry("claude", { enabled: false }), + ], + }, + }, + ); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("workspace-import-filters")).toBeNull(); + expect(screen.queryByTestId("workspace-import-filter-all")).toBeNull(); + }); + + it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => { + const fetchRecentProviderSessions = vi.fn(); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { + supportsSnapshot: true, + entries: [ + createSnapshotEntry("claude", { enabled: false }), + createSnapshotEntry("codex", { enabled: false }), + createSnapshotEntry("opencode", { enabled: false }), + createSnapshotEntry("z-ai"), + ], + }, + }, + ); + + await screen.findByText("No importable providers are enabled."); + expect(fetchRecentProviderSessions).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-import-sheet.tsx b/packages/app/src/screens/workspace/workspace-import-sheet.tsx new file mode 100644 index 000000000..9073254e0 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-import-sheet.tsx @@ -0,0 +1,615 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; +import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { getProviderIcon } from "@/components/provider-icons"; +import { formatTimeAgo } from "@/utils/time"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; + +const IMPORTABLE_PROVIDER_IDS: Set = new Set(IMPORTABLE_PROVIDERS); +const PER_PROVIDER_LIMIT = 15; +const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"]; +const DISABLED_ACCESSIBILITY_STATE = { disabled: true }; + +const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const activityIndicatorColorMapping = (theme: { colors: { foregroundMuted: string } }) => ({ + color: theme.colors.foregroundMuted, +}); + +type RecentProviderSessionsClient = Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" +>; + +interface WorkspaceImportSheetProps { + visible: boolean; + client: RecentProviderSessionsClient | null; + serverId: string | null; + workspaceDirectory: string | null; + onClose: () => void; + onImportedAgent: (agentId: string) => void; +} + +type RecentSessionsResponse = Awaited< + ReturnType +>; + +interface SessionsQueryConfig { + queryKey: ReadonlyArray; + enabled: boolean; + queryFn: () => Promise; +} + +interface SessionsQueryResult { + data: RecentSessionsResponse | undefined; + isError: boolean; + isLoading: boolean; + isPending: boolean; +} + +function resolveProvidersToFetch( + supportsSnapshot: boolean, + snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined, +): AgentProvider[] | null { + // COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside + // providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both — + // we render an "update host" empty state instead of degrading. Drop this gate + // when the supported daemon floor is >= v0.1.48 (target: 2026-10-05). + if (!supportsSnapshot) return null; + if (!snapshotEntries) return null; + return snapshotEntries + .filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false) + .map((entry) => entry.provider); +} + +function buildProviderLabelMap( + snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined, +): Map { + const map = new Map(); + if (!snapshotEntries) return map; + for (const entry of snapshotEntries) { + if (entry.label) { + map.set(entry.provider, entry.label); + } + } + return map; +} + +function buildSessionsQueriesConfig(args: { + providersToFetch: AgentProvider[] | null; + sessionsQueryRoot: ReadonlyArray; + visible: boolean; + client: RecentProviderSessionsClient | null; + workspaceDirectory: string | null; +}): SessionsQueryConfig[] { + const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args; + if (providersToFetch === null) return []; + const enabled = visible && Boolean(client && workspaceDirectory); + return providersToFetch.map((provider) => ({ + queryKey: [...sessionsQueryRoot, provider], + enabled, + queryFn: async () => { + if (!client || !workspaceDirectory) { + throw new Error("Host is not connected"); + } + return await client.fetchRecentProviderSessions({ + cwd: workspaceDirectory, + providers: [provider], + limit: PER_PROVIDER_LIMIT, + }); + }, + })); +} + +function aggregateSessionEntries( + queries: ReadonlyArray, +): FetchRecentProviderSessionEntry[] { + const seen = new Set(); + const collected: FetchRecentProviderSessionEntry[] = []; + for (const query of queries) { + if (!query.data) continue; + for (const entry of query.data.entries) { + const key = `${entry.providerId}:${entry.providerHandleId}`; + if (seen.has(key)) continue; + seen.add(key); + collected.push(entry); + } + } + collected.sort( + (a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(), + ); + return collected; +} + +function sumFilteredAlreadyImportedCount(queries: ReadonlyArray): number { + let total = 0; + for (const query of queries) { + total += query.data?.filteredAlreadyImportedCount ?? 0; + } + return total; +} + +function collectErroredProviderLabels( + providersToFetch: AgentProvider[] | null, + queries: ReadonlyArray, + providerLabelById: ReadonlyMap, +): string[] { + if (providersToFetch === null) return []; + const labels: string[] = []; + for (let index = 0; index < queries.length; index++) { + if (queries[index]?.isError) { + const provider = providersToFetch[index]; + labels.push(providerLabelById.get(provider) ?? provider); + } + } + return labels; +} + +function getSessionTitle(entry: FetchRecentProviderSessionEntry): string { + const title = entry.title?.trim(); + if (title) { + return title; + } + const firstPromptPreview = entry.firstPromptPreview?.trim(); + if (firstPromptPreview) { + return firstPromptPreview; + } + return "Untitled session"; +} + +function getPromptPreview(entry: FetchRecentProviderSessionEntry): string { + return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview"; +} + +interface SheetStatusMessagesProps { + isClientReady: boolean; + isSnapshotUnsupported: boolean; + hasNoImportableProviders: boolean; + isLoadingSessions: boolean; + allQueriesErrored: boolean; + erroredProviderLabels: ReadonlyArray; + importErrored: boolean; + showEmptyState: boolean; + allAlreadyImported: boolean; +} + +function SheetStatusMessages({ + isClientReady, + isSnapshotUnsupported, + hasNoImportableProviders, + isLoadingSessions, + allQueriesErrored, + erroredProviderLabels, + importErrored, + showEmptyState, + allAlreadyImported, +}: SheetStatusMessagesProps) { + if (!isClientReady) { + return Connect to a workspace to import agents.; + } + if (isSnapshotUnsupported) { + return Update the host to import sessions.; + } + return ( + <> + {hasNoImportableProviders ? ( + No importable providers are enabled. + ) : null} + {isLoadingSessions ? ( + + + Loading recent sessions... + + ) : null} + {allQueriesErrored ? ( + Could not load recent sessions. + ) : null} + {!allQueriesErrored && erroredProviderLabels.length > 0 ? ( + + Could not load sessions for {erroredProviderLabels.join(", ")}. + + ) : null} + {importErrored ? ( + Could not import selected session. + ) : null} + {showEmptyState ? ( + + {allAlreadyImported + ? "All recent sessions are already imported." + : "No recent sessions to import."} + + ) : null} + + ); +} + +interface ProviderFilterBadgeProps { + testID: string; + label: string; + selected: boolean; + onPress: () => void; +} + +function ProviderFilterBadge({ testID, label, selected, onPress }: ProviderFilterBadgeProps) { + const accessibilityState = useMemo(() => ({ selected }), [selected]); + const containerStyle = useMemo( + () => [ + styles.filterBadge, + selected ? styles.filterBadgeSelected : styles.filterBadgeUnselected, + ], + [selected], + ); + const textStyle = useMemo( + () => [ + styles.filterBadgeLabel, + selected ? styles.filterBadgeLabelSelected : styles.filterBadgeLabelUnselected, + ], + [selected], + ); + + return ( + + + {label} + + + ); +} + +function ProviderFilterBadgeItem({ + provider, + label, + selected, + onSelect, +}: { + provider: string; + label: string; + selected: boolean; + onSelect: (provider: string) => void; +}) { + const handlePress = useCallback(() => onSelect(provider), [onSelect, provider]); + return ( + + ); +} + +function WorkspaceImportSheetRow({ + entry, + disabled, + importing, + onImportSession, +}: { + entry: FetchRecentProviderSessionEntry; + disabled: boolean; + importing: boolean; + onImportSession: (entry: FetchRecentProviderSessionEntry) => void; +}) { + const { theme } = useUnistyles(); + const title = getSessionTitle(entry); + const promptPreview = getPromptPreview(entry); + const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt)); + const ProviderIcon = getProviderIcon(entry.providerId); + const accessibilityState = useMemo( + () => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined), + [disabled], + ); + const handlePress = useCallback(() => { + onImportSession(entry); + }, [entry, onImportSession]); + + return ( + + + + + + {entry.providerLabel} + + + {lastActivity} + + + {title} + + + {promptPreview} + + {importing ? Importing... : null} + + ); +} + +export function WorkspaceImportSheet({ + visible, + client, + serverId, + workspaceDirectory, + onClose, + onImportedAgent, +}: WorkspaceImportSheetProps) { + const queryClient = useQueryClient(); + + const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, { + enabled: visible, + }); + + const providersToFetch = useMemo( + () => resolveProvidersToFetch(supportsSnapshot, snapshotEntries), + [supportsSnapshot, snapshotEntries], + ); + + const providerLabelById = useMemo( + () => buildProviderLabelMap(snapshotEntries), + [snapshotEntries], + ); + + const sessionsQueryRoot = useMemo( + () => ["recent-provider-sessions", workspaceDirectory] as const, + [workspaceDirectory], + ); + + const queriesConfig = useMemo( + () => + buildSessionsQueriesConfig({ + providersToFetch, + sessionsQueryRoot, + visible, + client, + workspaceDirectory, + }), + [providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory], + ); + + const queries = useQueries({ queries: queriesConfig }); + + const aggregatedEntries = useMemo(() => aggregateSessionEntries(queries), [queries]); + const totalAlreadyImportedCount = useMemo( + () => sumFilteredAlreadyImportedCount(queries), + [queries], + ); + + const badgeProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]); + + const [selectedProvider, setSelectedProvider] = useState(null); + + useEffect(() => { + if (!visible || (selectedProvider !== null && !badgeProviders.includes(selectedProvider))) { + setSelectedProvider(null); + } + }, [visible, badgeProviders, selectedProvider]); + + const visibleEntries = useMemo(() => { + if (selectedProvider === null) return aggregatedEntries; + return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider); + }, [aggregatedEntries, selectedProvider]); + + const handleSelectAll = useCallback(() => setSelectedProvider(null), []); + + const importMutation = useMutation({ + mutationFn: async (entry: FetchRecentProviderSessionEntry) => { + if (!client || !workspaceDirectory) { + throw new Error("Host is not connected"); + } + const agent = await client.importAgent({ + providerId: entry.providerId, + providerHandleId: entry.providerHandleId, + cwd: workspaceDirectory, + }); + return agent; + }, + onSuccess: async (agent) => { + await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot }); + onClose(); + onImportedAgent(agent.id); + }, + }); + + const importingSessionKey = + importMutation.isPending && importMutation.variables + ? `${importMutation.variables.providerId}:${importMutation.variables.providerHandleId}` + : null; + + const handleImportSession = useCallback( + (entry: FetchRecentProviderSessionEntry) => { + importMutation.mutate(entry); + }, + [importMutation], + ); + + const erroredProviderLabels = useMemo( + () => collectErroredProviderLabels(providersToFetch, queries, providerLabelById), + [queries, providersToFetch, providerLabelById], + ); + + const isSnapshotUnsupported = !supportsSnapshot; + const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined; + const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0; + const isQueryingProviders = queries.length > 0; + const isLoadingSessions = + isWaitingForSnapshot || + (isQueryingProviders && queries.some((query) => query.isLoading || query.isPending)); + const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError); + const allQueriesSettled = + isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending); + const showEmptyState = + !isLoadingSessions && + !allQueriesErrored && + isQueryingProviders && + allQueriesSettled && + aggregatedEntries.length === 0; + const allAlreadyImported = showEmptyState && totalAlreadyImportedCount > 0; + const showBadges = badgeProviders.length > 1; + + return ( + + {showBadges ? ( + + + {badgeProviders.map((provider) => ( + + ))} + + ) : null} + + {visibleEntries.length > 0 ? ( + + {visibleEntries.map((entry) => ( + + ))} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + list: { + gap: theme.spacing[2], + }, + row: { + gap: theme.spacing[1], + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[3], + }, + rowHeader: { + minWidth: 0, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + }, + rowProvider: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + providerLabel: { + flex: 1, + minWidth: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + }, + filterRow: { + flexDirection: "row", + gap: theme.spacing[2], + paddingBottom: theme.spacing[2], + }, + filterBadge: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.full, + borderWidth: theme.borderWidth[1], + }, + filterBadgeSelected: { + backgroundColor: theme.colors.surface3, + borderColor: theme.colors.borderAccent, + }, + filterBadgeUnselected: { + backgroundColor: "transparent", + borderColor: theme.colors.border, + }, + filterBadgeLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + }, + filterBadgeLabelSelected: { + color: theme.colors.foreground, + }, + filterBadgeLabelUnselected: { + color: theme.colors.foregroundMuted, + }, + lastActivity: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + rowTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + }, + promptPreview: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: 19, + }, + importingText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + statusRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + statusText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index cfc8723e5..5bb8cf134 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -24,6 +24,7 @@ import { Ellipsis, EllipsisVertical, Globe, + Import as ImportIcon, PanelRight, RotateCw, Settings, @@ -57,6 +58,7 @@ import { SourceControlPanelIcon } from "@/components/icons/source-control-panel- import { WorkspaceGitActions } from "@/git/workspace-actions"; import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; +import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { useToast } from "@/contexts/toast-context"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; @@ -174,6 +176,7 @@ const ThemedX = withUnistyles(X); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); +const ThemedImport = withUnistyles(ImportIcon); const ThemedSettings = withUnistyles(Settings); const ThemedPanelRight = withUnistyles(PanelRight); const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon); @@ -186,6 +189,7 @@ const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 }; const MENU_NEW_AGENT_ICON = ; const MENU_NEW_TERMINAL_ICON = ; const MENU_NEW_BROWSER_ICON = ; +const MENU_IMPORT_ICON = ; const MENU_COPY_ICON = ; const MENU_SETTINGS_ICON = ; const GATED_WORKSPACE_HEADER_LEFT = ; @@ -777,14 +781,17 @@ interface WorkspaceHeaderMenuProps { showCreateBrowserTab: boolean; isMobile: boolean; createTerminalDisabled: boolean; + importAgentDisabled: boolean; menuNewAgentIcon: ReactElement; menuNewTerminalIcon: ReactElement; menuNewBrowserIcon: ReactElement; + menuImportIcon: ReactElement; menuCopyIcon: ReactElement; menuSettingsIcon: ReactElement; onCreateDraftTab: () => void; onCreateTerminal: () => void; onCreateBrowser: () => void; + onOpenImportSheet: () => void; onCopyWorkspacePath: () => void; onCopyBranchName: () => void; onOpenSetupTab: () => void; @@ -811,14 +818,17 @@ function WorkspaceHeaderMenu({ showCreateBrowserTab, isMobile, createTerminalDisabled, + importAgentDisabled, menuNewAgentIcon, menuNewTerminalIcon, menuNewBrowserIcon, + menuImportIcon, menuCopyIcon, menuSettingsIcon, onCreateDraftTab, onCreateTerminal, onCreateBrowser, + onOpenImportSheet, onCopyWorkspacePath, onCopyBranchName, onOpenSetupTab, @@ -865,6 +875,14 @@ function WorkspaceHeaderMenu({ New browser tab ) : null} + + Import agent... + void; onCreateTerminal: () => void; onCreateBrowser: () => void; + onOpenImportSheet: () => void; onCopyWorkspacePath: () => void; onCopyBranchName: () => void; onOpenSetupTab: () => void; @@ -938,14 +959,17 @@ function WorkspaceHeaderTitleBar({ showCreateBrowserTab, isMobile, createTerminalDisabled, + importAgentDisabled, menuNewAgentIcon, menuNewTerminalIcon, menuNewBrowserIcon, + menuImportIcon, menuCopyIcon, menuSettingsIcon, onCreateDraftTab, onCreateTerminal, onCreateBrowser, + onOpenImportSheet, onCopyWorkspacePath, onCopyBranchName, onOpenSetupTab, @@ -983,14 +1007,17 @@ function WorkspaceHeaderTitleBar({ showCreateBrowserTab={showCreateBrowserTab} isMobile={isMobile} createTerminalDisabled={createTerminalDisabled} + importAgentDisabled={importAgentDisabled} menuNewAgentIcon={menuNewAgentIcon} menuNewTerminalIcon={menuNewTerminalIcon} menuNewBrowserIcon={menuNewBrowserIcon} + menuImportIcon={menuImportIcon} menuCopyIcon={menuCopyIcon} menuSettingsIcon={menuSettingsIcon} onCreateDraftTab={onCreateDraftTab} onCreateTerminal={onCreateTerminal} onCreateBrowser={onCreateBrowser} + onOpenImportSheet={onOpenImportSheet} onCopyWorkspacePath={onCopyWorkspacePath} onCopyBranchName={onCopyBranchName} onOpenSetupTab={onOpenSetupTab} @@ -1388,6 +1415,14 @@ function WorkspaceScreenContent({ ); const { workspaceDirectory, isMissingWorkspaceExecutionAuthority } = resolveWorkspaceAuthorityState(workspaceAuthority, workspaceDescriptor); + const [isImportSheetVisible, setIsImportSheetVisible] = useState(false); + const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean); + const openImportSheet = useCallback(() => { + setIsImportSheetVisible(true); + }, []); + const closeImportSheet = useCallback(() => { + setIsImportSheetVisible(false); + }, []); // Warm the global provider snapshot so the model picker is ready when opened. useProvidersSnapshot(normalizedServerId, { @@ -1933,6 +1968,18 @@ function WorkspaceScreenContent({ }, [focusWorkspaceTab, persistenceKey], ); + const handleImportedAgent = useCallback( + (agentId: string) => { + if (!persistenceKey) { + return; + } + const tabId = openWorkspaceTabFocused(persistenceKey, { kind: "agent", agentId }); + if (tabId) { + navigateToTabId(tabId); + } + }, + [navigateToTabId, openWorkspaceTabFocused, persistenceKey], + ); const emptyWorkspaceSeedRef = useRef(null); const autoOpenedSetupTabWorkspaceRef = useRef(null); @@ -3212,14 +3259,17 @@ function WorkspaceScreenContent({ showCreateBrowserTab={showCreateBrowserTab} isMobile={isMobile} createTerminalDisabled={createTerminalDisabled} + importAgentDisabled={!canOpenImportSheet} menuNewAgentIcon={menuNewAgentIcon} menuNewTerminalIcon={menuNewTerminalIcon} menuNewBrowserIcon={MENU_NEW_BROWSER_ICON} + menuImportIcon={MENU_IMPORT_ICON} menuCopyIcon={menuCopyIcon} menuSettingsIcon={menuSettingsIcon} onCreateDraftTab={handleCreateDraftTab} onCreateTerminal={handleCreateTerminal} onCreateBrowser={handleCreateBrowserTab} + onOpenImportSheet={openImportSheet} onCopyWorkspacePath={handleCopyWorkspacePath} onCopyBranchName={handleCopyBranchName} onOpenSetupTab={handleOpenSetupTab} @@ -3301,6 +3351,14 @@ function WorkspaceScreenContent({ /> ) : null} + ) diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 676982226..c94d1a3b7 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1849,6 +1849,185 @@ test("fetches paginated agent history separately from active agents", async () = }); }); +test("fetches scoped recent provider sessions", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.fetchRecentProviderSessions({ + cwd: "/tmp/repo", + providers: ["my-claude"], + since: "2026-04-30T00:00:00.000Z", + limit: 25, + }); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "fetch_recent_provider_sessions_request"; + requestId: string; + cwd?: string; + providers?: string[]; + since?: string; + limit?: number; + }; + }; + expect(request.message).toMatchObject({ + type: "fetch_recent_provider_sessions_request", + cwd: "/tmp/repo", + providers: ["my-claude"], + since: "2026-04-30T00:00:00.000Z", + limit: 25, + }); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "fetch_recent_provider_sessions_response", + payload: { + requestId: request.message.requestId, + entries: [ + { + providerId: "codex", + providerLabel: "Codex", + providerHandleId: "thread-1", + cwd: "/tmp/repo", + title: "Import me", + firstPromptPreview: "first prompt", + lastPromptPreview: "last prompt", + lastActivityAt: "2026-04-30T12:34:56.000Z", + }, + ], + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + requestId: request.message.requestId, + entries: [ + { + providerId: "codex", + providerLabel: "Codex", + providerHandleId: "thread-1", + cwd: "/tmp/repo", + title: "Import me", + firstPromptPreview: "first prompt", + lastPromptPreview: "last prompt", + lastActivityAt: "2026-04-30T12:34:56.000Z", + }, + ], + }); +}); + +test("imports an agent by provider handle id", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.importAgent({ + providerId: "custom-codex", + providerHandleId: "thread-1", + cwd: "/tmp/repo", + }); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "import_agent_request"; + requestId: string; + providerId?: string; + providerHandleId?: string; + sessionId?: string; + cwd?: string; + }; + }; + expect(request.message).toMatchObject({ + type: "import_agent_request", + providerId: "custom-codex", + providerHandleId: "thread-1", + cwd: "/tmp/repo", + }); + expect(request.message).not.toHaveProperty("sessionId"); + + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_resumed", + requestId: request.message.requestId, + agentId: "agent-1", + timelineSize: 0, + agent: { + id: "agent-1", + provider: "custom-codex", + cwd: "/tmp/repo", + model: null, + features: [], + thinkingOptionId: null, + effectiveThinkingOptionId: null, + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: { + provider: "custom-codex", + sessionId: "thread-1", + nativeHandle: "thread-1", + }, + title: null, + labels: {}, + requiresAttention: false, + attentionReason: null, + }, + }, + }), + ); + + await expect(promise).resolves.toMatchObject({ + id: "agent-1", + provider: "custom-codex", + }); +}); + test("uses server-provided dictation finish timeout budget", async () => { vi.useFakeTimers(); const logger = createMockLogger(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 895c77e03..9d766653b 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -123,13 +123,21 @@ const perfNow: () => number = ? () => performance.now() : () => Date.now(); -export interface ImportAgentInput { - provider: AgentProvider; - sessionId: string; +interface ImportAgentInputBase { cwd?: string; labels?: Record; } +export type ImportAgentInput = + | (ImportAgentInputBase & { + providerId: string; + providerHandleId: string; + }) + | (ImportAgentInputBase & { + provider: AgentProvider; + sessionId: string; + }); + function normalizePassword(value: string | undefined): string | null { if (typeof value !== "string") { return null; @@ -438,6 +446,21 @@ export type FetchAgentHistoryOptions = Omit["payload"]; +type FetchRecentProviderSessionsRequest = Extract< + SessionInboundMessage, + { type: "fetch_recent_provider_sessions_request" } +>; +export type FetchRecentProviderSessionsOptions = Omit< + FetchRecentProviderSessionsRequest, + "type" | "requestId" +> & { + requestId?: string; +}; +export type FetchRecentProviderSessionEntry = FetchRecentProviderSessionsPayload["entries"][number]; type FetchWorkspacesPayload = Extract< SessionOutboundMessage, { type: "fetch_workspaces_response" } @@ -1553,6 +1576,35 @@ export class DaemonClient { }); } + async fetchRecentProviderSessions( + options?: FetchRecentProviderSessionsOptions, + ): Promise { + const resolvedRequestId = this.createRequestId(options?.requestId); + const message = SessionInboundMessageSchema.parse({ + type: "fetch_recent_provider_sessions_request", + requestId: resolvedRequestId, + ...(options?.cwd ? { cwd: options.cwd } : {}), + ...(options?.providers ? { providers: options.providers } : {}), + ...(options?.since ? { since: options.since } : {}), + ...(options?.limit ? { limit: options.limit } : {}), + }); + return this.sendRequest({ + requestId: resolvedRequestId, + message, + timeout: 10000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "fetch_recent_provider_sessions_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + }); + } + async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise { const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ @@ -1902,8 +1954,9 @@ export class DaemonClient { const message = SessionInboundMessageSchema.parse({ type: "import_agent_request", requestId, - provider: input.provider, - sessionId: input.sessionId, + ...("providerId" in input + ? { providerId: input.providerId, providerHandleId: input.providerHandleId } + : { provider: input.provider, sessionId: input.sessionId }), ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}), }); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 72d7a7dd6..d9a96a9ed 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1090,6 +1090,17 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p return new TestAgentSession(config); } + async listModels() { + return [ + { + provider: "codex", + id: "gpt-5.4", + label: "GPT-5.4", + isDefault: true, + }, + ]; + } + async resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, @@ -1156,6 +1167,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p }, }); expect(client.lastResumeOverrides).toMatchObject({ + model: "gpt-5.4", modeId: "auto", systemPrompt: "new prompt", mcpServers: { @@ -5506,3 +5518,109 @@ test("replaceAgentRun succeeds when foreground turn terminal event is never deli expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); expect(manager.getAgent(snapshot.id)?.activeForegroundTurnId).toBeNull(); }, 10_000); + +class RecordingPersistedAgentsClient implements AgentClient { + readonly capabilities = TEST_CAPABILITIES; + calls = 0; + + constructor(public readonly provider: AgentProvider) {} + + async isAvailable(): Promise { + return true; + } + + async createSession(): Promise { + throw new Error(`unexpected createSession for ${this.provider}`); + } + + async resumeSession(): Promise { + throw new Error(`unexpected resumeSession for ${this.provider}`); + } + + async listModels() { + return []; + } + + async listPersistedAgents(): Promise { + this.calls += 1; + return [ + { + provider: this.provider, + sessionId: `${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: [], + }, + ]; + } +} + +test.each([ + [ + "disabled", + "claude", + "codex", + { + claude: { enabled: true, derivedFromProviderId: null }, + codex: { enabled: false, derivedFromProviderId: null }, + }, + ], + [ + "derived", + "claude", + "zai", + { + claude: { enabled: true, derivedFromProviderId: null }, + zai: { enabled: true, derivedFromProviderId: "claude" }, + }, + ], + [ + "outside importable allowlist", + "claude", + "gemini", + { + claude: { enabled: true, derivedFromProviderId: null }, + gemini: { enabled: true, derivedFromProviderId: null }, + }, + ], +])( + "listImportablePersistedAgents skips %s providers in fan-out", + async (_reason, includedProvider, skippedProvider, providerDefinitions) => { + const includedClient = new RecordingPersistedAgentsClient(includedProvider); + const skippedClient = new RecordingPersistedAgentsClient(skippedProvider); + const manager = new AgentManager({ + clients: { [includedProvider]: includedClient, [skippedProvider]: skippedClient }, + providerDefinitions, + logger, + }); + + const result = await manager.listImportablePersistedAgents(); + + expect(includedClient.calls).toBe(1); + expect(skippedClient.calls).toBe(0); + expect(result.map((d) => d.provider)).toEqual([includedProvider]); + }, +); + +test("listImportablePersistedAgents narrows to the providerFilter when supplied", async () => { + const claudeClient = new RecordingPersistedAgentsClient("claude"); + const codexClient = new RecordingPersistedAgentsClient("codex"); + const manager = new AgentManager({ + clients: { claude: claudeClient, codex: codexClient }, + providerDefinitions: { + claude: { enabled: true, derivedFromProviderId: null }, + codex: { enabled: true, derivedFromProviderId: null }, + }, + logger, + }); + + const result = await manager.listImportablePersistedAgents({ + providerFilter: new Set(["claude"]), + }); + + expect(claudeClient.calls).toBe(1); + expect(codexClient.calls).toBe(0); + expect(result.map((d) => d.provider)).toEqual(["claude"]); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 614d136ff..199a4b71f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -52,6 +52,7 @@ import { } from "./agent-stream-coalescer.js"; import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js"; import { getAgentProviderDefinition } from "./provider-manifest.js"; +import { IMPORTABLE_PROVIDERS } from "./provider-registry.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; @@ -129,8 +130,12 @@ export interface SubscribeOptions { replayState?: boolean; } -export type PersistedAgentQueryOptions = ListPersistedAgentsOptions & { - provider?: AgentProvider; +export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & { + /** + * When set, only providers in this set are scanned, in addition to the + * built-in importable allowlist + enabled + non-derived rules. + */ + providerFilter?: Set; }; export type AgentAttentionCallback = (params: { @@ -152,6 +157,7 @@ interface AgentManagerRescueTimeouts { interface ProviderEnabledFlag { enabled: boolean; + derivedFromProviderId?: string | null; } type ProviderEnabledMap = Partial>; type ProviderClientMap = Partial>; @@ -416,6 +422,7 @@ function buildExplicitTimelineSeedForRegister( export class AgentManager { private readonly clients = new Map(); private readonly providerEnabled = new Map(); + private readonly providerDerivedFromId = new Map(); private readonly agents = new Map(); private readonly timelineStore = new InMemoryAgentTimelineStore(); private readonly agentsAwaitingInitialSnapshotPersist = new Set(); @@ -471,6 +478,7 @@ export class AgentManager { for (const [provider, definition] of Object.entries(input.providerDefinitions)) { if (definition) { this.providerEnabled.set(provider, definition.enabled); + this.providerDerivedFromId.set(provider, definition.derivedFromProviderId ?? null); } } for (const [provider, client] of Object.entries(input.clients)) { @@ -592,24 +600,21 @@ export class AgentManager { .map((agent) => Object.assign({}, agent)); } - async listPersistedAgents( - options?: PersistedAgentQueryOptions, + async listImportablePersistedAgents( + options?: ImportablePersistedAgentQueryOptions, ): Promise { - if (options?.provider) { - const client = this.requireClient(options.provider); - if (!client.listPersistedAgents) { - return []; - } - return client.listPersistedAgents({ limit: options.limit }); - } - const providerEntries = Array.from(this.clients.entries()).filter( - ([, client]) => !!client.listPersistedAgents, + ([provider, client]) => + !!client.listPersistedAgents && + this.isProviderImportable(provider, options?.providerFilter), ); const descriptorLists = await Promise.all( providerEntries.map(async ([provider, client]) => { try { - return await client.listPersistedAgents!({ limit: options?.limit }); + return await client.listPersistedAgents!({ + limit: options?.limit, + cwd: options?.cwd, + }); } catch (error) { this.logger.warn( { err: error, provider }, @@ -627,6 +632,25 @@ export class AgentManager { .slice(0, limit); } + private isProviderImportable( + provider: AgentProvider, + providerFilter: Set | undefined, + ): boolean { + if (!IMPORTABLE_PROVIDERS.includes(provider as (typeof IMPORTABLE_PROVIDERS)[number])) { + return false; + } + if (this.providerEnabled.get(provider) === false) { + return false; + } + if (this.providerDerivedFromId.get(provider) != null) { + return false; + } + if (providerFilter && !providerFilter.has(provider)) { + return false; + } + return true; + } + async findPersistedAgent( provider: AgentProvider, sessionId: string, diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index 62a329215..5fb6ca975 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from "vitest"; import { AGENT_LIFECYCLE_STATUSES } from "./agent-manager.js"; -import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js"; +import { + toAgentPayload, + toRecentProviderSessionDescriptorPayload, + toStoredAgentRecord, + type ManagedAgent, +} from "./agent-projections.js"; import type { AgentSession } from "./agent-sdk-types.js"; import type { AgentFeature, AgentPermissionRequest, AgentPersistenceHandle, AgentSessionConfig, + PersistedAgentDescriptor, } from "./agent-sdk-types.js"; type ManagedAgentOverrides = Omit, "config" | "pendingPermissions"> & { @@ -392,3 +398,70 @@ describe("toAgentPayload", () => { expect(payload.features).toEqual(features); }); }); + +describe("toRecentProviderSessionDescriptorPayload", () => { + it("projects persisted descriptors to provider-opaque public recent sessions", () => { + const descriptor: PersistedAgentDescriptor = { + provider: "codex-custom", + sessionId: "legacy-session-id", + cwd: "/tmp/project", + title: "Import me", + 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, { + providerLabel: "Custom Codex", + }); + + expect(payload).toEqual({ + providerId: "codex-custom", + providerLabel: "Custom Codex", + providerHandleId: "provider-native-handle", + cwd: "/tmp/project", + title: "Import me", + firstPromptPreview: "First prompt with spacing", + lastPromptPreview: "Second prompt", + lastActivityAt: "2026-04-30T12:34:56.000Z", + }); + expect(payload).not.toHaveProperty("providerKind"); + expect(payload).not.toHaveProperty("sessionId"); + expect(payload).not.toHaveProperty("nativeHandle"); + }); + + it("falls back to persistence session id when no provider native handle exists", () => { + const descriptor: PersistedAgentDescriptor = { + provider: "claude-custom", + sessionId: "descriptor-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: [], + }; + + expect( + toRecentProviderSessionDescriptorPayload(descriptor, { + providerLabel: "Custom Claude", + }), + ).toMatchObject({ + providerId: "claude-custom", + providerLabel: "Custom Claude", + providerHandleId: "persistence-session-id", + firstPromptPreview: null, + lastPromptPreview: null, + }); + }); +}); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 3296a9362..49d1ffe4e 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -1,4 +1,8 @@ -import type { AgentListItemPayload, AgentSnapshotPayload } from "../messages.js"; +import type { + AgentListItemPayload, + AgentSnapshotPayload, + RecentProviderSessionDescriptorPayload, +} from "../messages.js"; import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js"; import type { AgentCapabilityFlags, @@ -10,7 +14,9 @@ import type { AgentProvider, AgentSessionConfig, AgentRuntimeInfo, + AgentTimelineItem, AgentUsage, + PersistedAgentDescriptor, } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { JsonValue } from "../json-utils.js"; @@ -23,6 +29,12 @@ interface ProjectionOptions { internal?: boolean; } +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(); @@ -247,6 +259,24 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt }; } +export function toRecentProviderSessionDescriptorPayload( + descriptor: PersistedAgentDescriptor, + options: RecentProviderSessionProjectionOptions, +): RecentProviderSessionDescriptorPayload { + const promptPreviews = collectPromptPreviews(descriptor.timeline); + + return { + providerId: descriptor.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(), + }; +} + export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string { const timestamps = [record.updatedAt, record.lastActivityAt] .filter((value): value is string => typeof value === "string" && value.length > 0) @@ -264,6 +294,26 @@ 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 (Object.prototype.hasOwnProperty.call(config, "title")) { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 3928ffb72..a5a03e563 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -427,6 +427,13 @@ export interface AgentSlashCommand { export interface ListPersistedAgentsOptions { 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. + */ + cwd?: string; } export interface PersistedAgentDescriptor { diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts new file mode 100644 index 000000000..cb01d7a1a --- /dev/null +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -0,0 +1,226 @@ +import { expect, test, vi } from "vitest"; +import type { AgentManager, ManagedAgent } from "./agent-manager.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; +import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js"; +import type { PersistedAgentDescriptor } from "./agent-sdk-types.js"; +import { + ImportSessionsRequestError, + listImportableProviderSessions, + normalizeImportAgentRequest, +} from "./import-sessions.js"; + +function makeDescriptor(args: { + provider?: string; + sessionId: string; + nativeHandle?: string; + cwd?: string; + title?: string | null; + lastActivityAt: string; + firstPrompt?: string; + lastPrompt?: string; +}): PersistedAgentDescriptor { + const provider = args.provider ?? "codex"; + const cwd = args.cwd ?? "/tmp/project"; + return { + provider, + sessionId: 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 }] : []), + ], + }; +} + +function makeRequest( + overrides: Partial = {}, +): FetchRecentProviderSessionsRequestMessage { + return { + type: "fetch_recent_provider_sessions_request", + requestId: "recent-provider-sessions", + ...overrides, + }; +} + +test("listImportableProviderSessions filters, sorts, limits, and projects importable sessions", async () => { + const cwd = "/tmp/project"; + const descriptors = [ + makeDescriptor({ + sessionId: "outside-cwd", + nativeHandle: "outside-cwd-handle", + cwd: "/tmp/elsewhere", + title: "Outside cwd", + lastActivityAt: "2026-04-30T12:05:00.000Z", + }), + makeDescriptor({ + sessionId: "stored-session", + nativeHandle: "stored-handle", + cwd, + title: "Already stored", + lastActivityAt: "2026-04-30T12:04:00.000Z", + }), + makeDescriptor({ + sessionId: "older-session", + nativeHandle: "older-handle", + cwd, + title: "Older than since", + lastActivityAt: "2026-04-29T23:59:59.000Z", + }), + makeDescriptor({ + sessionId: "newer-session", + nativeHandle: "newer-handle", + cwd, + title: "Newer import", + lastActivityAt: "2026-04-30T12:02:00.000Z", + firstPrompt: "newer first prompt", + lastPrompt: "newer last prompt", + }), + makeDescriptor({ + sessionId: "second-session", + nativeHandle: "second-handle", + cwd, + title: "Second import", + lastActivityAt: "2026-04-30T12:00:00.000Z", + firstPrompt: "second prompt", + }), + makeDescriptor({ + sessionId: "third-session", + nativeHandle: "third-handle", + cwd, + title: "Third import", + lastActivityAt: "2026-04-30T11:59:00.000Z", + firstPrompt: "third prompt", + }), + makeDescriptor({ + sessionId: "live-session", + nativeHandle: "live-handle", + cwd, + title: "Already live", + lastActivityAt: "2026-04-30T12:01:00.000Z", + }), + ]; + const listImportablePersistedAgents = vi.fn(async () => descriptors); + const agentManager = { + listAgents: () => + [ + { + provider: "codex", + persistence: { + provider: "codex", + sessionId: "live-session", + nativeHandle: "live-handle", + }, + }, + ] as ManagedAgent[], + listImportablePersistedAgents, + } satisfies Pick; + const agentStorage = { + list: async () => [ + { + provider: "codex", + persistence: { + provider: "codex", + sessionId: "stored-session", + nativeHandle: "stored-handle", + }, + } as StoredAgentRecord, + ], + } satisfies Pick; + + const result = await listImportableProviderSessions({ + request: makeRequest({ + cwd, + providers: ["codex"], + since: "2026-04-30T00:00:00.000Z", + limit: 2, + }), + agentManager, + agentStorage, + providerRegistry: { codex: { label: "Codex" } }, + }); + + expect(listImportablePersistedAgents).toHaveBeenCalledWith({ + limit: 200, + providerFilter: new Set(["codex"]), + cwd, + }); + expect(result).toEqual({ + filteredAlreadyImportedCount: 2, + entries: [ + { + providerId: "codex", + providerLabel: "Codex", + providerHandleId: "newer-handle", + cwd, + title: "Newer import", + firstPromptPreview: "newer first prompt", + lastPromptPreview: "newer last prompt", + lastActivityAt: "2026-04-30T12:02:00.000Z", + }, + { + providerId: "codex", + providerLabel: "Codex", + providerHandleId: "second-handle", + cwd, + title: "Second import", + firstPromptPreview: "second prompt", + lastPromptPreview: "second prompt", + lastActivityAt: "2026-04-30T12:00:00.000Z", + }, + ], + }); +}); + +test("listImportableProviderSessions rejects invalid since values", async () => { + await expect( + listImportableProviderSessions({ + request: makeRequest({ since: "not-a-date" }), + agentManager: { + listAgents: () => [], + listImportablePersistedAgents: async () => [], + } satisfies Pick, + agentStorage: { + list: async () => [], + } satisfies Pick, + providerRegistry: {}, + }), + ).rejects.toMatchObject( + new ImportSessionsRequestError("invalid_since", "Invalid recent provider sessions since"), + ); +}); + +test("normalizeImportAgentRequest accepts new and legacy import handle shapes", () => { + expect( + normalizeImportAgentRequest({ + type: "import_agent_request", + requestId: "new-shape", + providerId: "custom-codex", + providerHandleId: "thread-1", + }), + ).toEqual({ + requestId: "new-shape", + provider: "custom-codex", + providerHandleId: "thread-1", + }); + + expect( + normalizeImportAgentRequest({ + type: "import_agent_request", + requestId: "legacy-shape", + provider: "codex", + sessionId: "thread-2", + }), + ).toEqual({ + requestId: "legacy-shape", + provider: "codex", + providerHandleId: "thread-2", + }); +}); diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts new file mode 100644 index 000000000..118b0b55d --- /dev/null +++ b/packages/server/src/server/agent/import-sessions.ts @@ -0,0 +1,160 @@ +import type { z } from "zod"; +import type { ProviderDefinition } from "./provider-registry.js"; +import type { AgentManager } from "./agent-manager.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; +import type { + AgentPersistenceHandle, + AgentProvider, + PersistedAgentDescriptor, +} from "./agent-sdk-types.js"; +import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js"; +import type { + FetchRecentProviderSessionsRequestMessage, + ImportAgentRequestMessageSchema, + RecentProviderSessionDescriptorPayload, +} from "../../shared/messages.js"; + +type ImportAgentRequestMessage = z.infer; + +export interface NormalizedImportAgentRequest { + provider: string; + providerHandleId: string; + cwd?: string; + labels?: Record; + requestId: string; +} + +export class ImportSessionsRequestError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = "ImportSessionsRequestError"; + } +} + +export interface ListImportableProviderSessionsInput { + request: FetchRecentProviderSessionsRequestMessage; + agentManager: Pick; + agentStorage: Pick; + providerRegistry: Record | undefined>; +} + +export interface ListImportableProviderSessionsResult { + entries: RecentProviderSessionDescriptorPayload[]; + filteredAlreadyImportedCount: number; +} + +// COMPAT(import-agent-request-v1): accept legacy {provider, sessionId} shape +// alongside the new {providerId, providerHandleId} shape. Old clients +// (< target daemon floor) send the legacy fields. Drop the fallbacks and the +// .optional() in messages.ts when the supported client floor is >= the daemon +// version that ships the new shape (target: 2026-11-08). +export function normalizeImportAgentRequest( + msg: ImportAgentRequestMessage, +): NormalizedImportAgentRequest | { error: string } { + const provider = msg.providerId ?? msg.provider; + const providerHandleId = msg.providerHandleId ?? msg.sessionId; + if (!provider || !providerHandleId) { + return { error: "Import requires providerId and providerHandleId" }; + } + return { + provider, + providerHandleId, + cwd: msg.cwd, + labels: msg.labels, + requestId: msg.requestId, + }; +} + +export async function listImportableProviderSessions( + input: ListImportableProviderSessionsInput, +): Promise { + const { request, agentManager, agentStorage, providerRegistry } = input; + const limit = request.limit ?? 20; + const sinceTimestamp = parseRecentProviderSessionsSince(request.since); + const providerFilter = request.providers ? new Set(request.providers) : undefined; + const importedHandles = await collectImportedProviderSessionHandles(agentManager, agentStorage); + + const descriptors = await agentManager.listImportablePersistedAgents({ + limit: 200, + providerFilter, + cwd: request.cwd, + }); + let filteredAlreadyImportedCount = 0; + const candidates: PersistedAgentDescriptor[] = []; + for (const descriptor of descriptors) { + if (request.cwd && descriptor.cwd !== request.cwd) { + continue; + } + if (sinceTimestamp !== null && descriptor.lastActivityAt.getTime() < sinceTimestamp) { + continue; + } + const providerHandleId = + descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId; + if (importedHandles.has(toProviderSessionHandleKey(descriptor.provider, providerHandleId))) { + filteredAlreadyImportedCount += 1; + continue; + } + candidates.push(descriptor); + } + + const entries = candidates + .sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()) + .slice(0, limit) + .map((descriptor) => + toRecentProviderSessionDescriptorPayload(descriptor, { + providerLabel: providerRegistry[descriptor.provider]?.label ?? descriptor.provider, + }), + ); + + return { entries, filteredAlreadyImportedCount }; +} + +function parseRecentProviderSessionsSince(since: string | undefined): number | null { + if (!since) { + return null; + } + const timestamp = Date.parse(since); + if (Number.isNaN(timestamp)) { + throw new ImportSessionsRequestError("invalid_since", "Invalid recent provider sessions since"); + } + return timestamp; +} + +async function collectImportedProviderSessionHandles( + agentManager: Pick, + agentStorage: Pick, +): Promise> { + const handles = new Set(); + + for (const agent of agentManager.listAgents()) { + collectProviderSessionHandleKeys(handles, agent.provider, agent.persistence); + } + + for (const record of await agentStorage.list()) { + collectProviderSessionHandleKeys(handles, record.provider, record.persistence); + } + + return handles; +} + +function toProviderSessionHandleKey(provider: string, providerHandleId: string): string { + return `${provider}\0${providerHandleId}`; +} + +function collectProviderSessionHandleKeys( + target: Set, + provider: AgentProvider | StoredAgentRecord["provider"] | string, + persistence: AgentPersistenceHandle | null | undefined, +): void { + if (!persistence) { + return; + } + + target.add(toProviderSessionHandleKey(provider, persistence.sessionId)); + if (persistence.nativeHandle) { + target.add(toProviderSessionHandleKey(provider, persistence.nativeHandle)); + } +} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 0c46b9461..85f215f4e 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -46,11 +46,19 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition }; export interface ProviderDefinition extends AgentProviderDefinition { enabled: boolean; + /** + * The id of another *registered* provider this one extends (e.g. a Z.AI + * profile that extends "claude"). null for built-in providers and for + * generic ACP providers (which only extend the literal "acp" sentinel). + */ + derivedFromProviderId: string | null; createClient: (logger: Logger) => AgentClient; fetchModels: (options: ListModelsOptions) => Promise; fetchModes: (options: ListModesOptions) => Promise; } +export { IMPORTABLE_PROVIDERS } from "../../shared/importable-providers.js"; + export interface BuildProviderRegistryOptions { runtimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; @@ -70,6 +78,7 @@ interface ResolvedProvider { profileModels: ProviderProfileModel[]; additionalModels: ProviderProfileModel[]; enabled: boolean; + derivedFromProviderId: string | null; createBaseClient: (logger: Logger) => AgentClient; } @@ -375,6 +384,7 @@ function createRegistryEntry( return { ...resolved.definition, enabled: resolved.enabled, + derivedFromProviderId: resolved.derivedFromProviderId, createClient: (providerLogger: Logger) => createResolvedProviderClient(providerLogger, provider, resolved), fetchModels: async (options: ListModelsOptions) => @@ -441,6 +451,7 @@ function buildResolvedBuiltinProviders( profileModels: override?.models ?? [], additionalModels: override?.additionalModels ?? [], enabled: override?.enabled !== false, + derivedFromProviderId: null, createBaseClient: (logger) => factory(logger, mergedRuntimeSettings, { workspaceGitService: options.workspaceGitService, @@ -487,6 +498,7 @@ function addDerivedProviders( profileModels: override.models ?? [], additionalModels: override.additionalModels ?? [], enabled: override.enabled !== false, + derivedFromProviderId: null, createBaseClient: (logger) => new GenericACPAgentClient({ logger, @@ -517,6 +529,7 @@ function addDerivedProviders( profileModels: override.models ?? [], additionalModels: override.additionalModels ?? [], enabled: override.enabled !== false, + derivedFromProviderId: override.extends, createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings), }); } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index d528912a0..cf7d1a226 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -15,6 +15,7 @@ import type { } from "../agent-sdk-types.js"; import { __codexAppServerInternals, + CodexAppServerAgentClient, codexAppServerTurnInputFromPrompt, } from "./codex-app-server-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; @@ -1656,3 +1657,62 @@ describe("Codex app-server provider", () => { ); }); }); + +describe("Codex persisted sessions", () => { + test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => { + const allThreads = [ + { + id: "thread-a1", + cwd: "/workspace/project-a", + preview: "First A session", + createdAt: 1000, + updatedAt: 2000, + }, + { + id: "thread-a2", + cwd: "/workspace/project-a", + preview: "Second A session", + createdAt: 1500, + updatedAt: 2500, + }, + { + id: "thread-b1", + cwd: "/workspace/project-b", + preview: "B session", + createdAt: 3000, + updatedAt: 4000, + }, + ]; + + const fakeClient = { + request: async (method: string) => { + if (method === "thread/list") return { data: allThreads }; + if (method === "thread/read") return { thread: { turns: [] } }; + return {}; + }, + notify: () => {}, + dispose: async () => {}, + }; + + const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, { + _createCodexClient: () => fakeClient, + }); + castInternals<{ spawnAppServer: () => Promise }>( + provider, + ).spawnAppServer = async () => { + const child = new EventEmitter() as ChildProcessWithoutNullStreams; + child.exitCode = 0; + child.signalCode = null; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"]; + return child; + }; + + const descriptors = await provider.listPersistedAgents({ 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); + }); +}); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 397112f77..03d5de9ab 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -170,8 +170,18 @@ const CODEX_MODES: AgentMode[] = [ const DEFAULT_CODEX_MODE_ID = "auto"; +interface CodexAppServerClientLike { + request(method: string, params?: unknown): Promise; + notify(method: string, params?: unknown): void; + dispose(): Promise; +} + interface CodexAppServerAgentDeps { workspaceGitService?: Pick; + _createCodexClient?: ( + child: ChildProcessWithoutNullStreams, + logger: Logger, + ) => CodexAppServerClientLike; } const MODE_PRESETS: Record< @@ -703,6 +713,20 @@ const CodexModelListResponseSchema = z.object({ .optional(), }); +function filterCodexThreadsByCwd( + threads: Array>, + cwd: string | undefined, +): Array> { + if (!cwd) { + return threads; + } + // thread/list rows carry an optional cwd. The descriptor builder later + // falls back to process.cwd() if the field is missing, so we only match + // here when the row genuinely carries a cwd string — otherwise threads + // with no cwd would falsely match the daemon's own cwd. + return threads.filter((thread) => typeof thread.cwd === "string" && thread.cwd === cwd); +} + class CodexAppServerClient { private readonly rl: readline.Interface; private readonly pending = new Map(); @@ -1731,7 +1755,7 @@ async function loadCodexThreadHistoryTimeline(params: { return timeline; } -function readCodexThread(client: CodexAppServerClient, threadId: string): Promise { +function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise { return client.request("thread/read", { threadId, includeTurns: true, @@ -4769,6 +4793,13 @@ export class CodexAppServerAgentClient implements AgentClient { launchContext?: AgentLaunchContext, options?: AgentCreateSessionOptions, ): Promise { + if (options?.persistSession === false) { + this.logger.debug( + "Codex app-server does not expose an ephemeral-session option; persistSession=false is currently a no-op", + ); + // TODO: Honor persistSession=false if app-server adds support, or route + // utility generations through `codex exec --ephemeral` in a larger change. + } const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; const goalsEnabled = await this.resolveGoalsEnabled(); const session = new CodexAppServerAgentSession( @@ -4814,15 +4845,23 @@ export class CodexAppServerAgentClient implements AgentClient { options?: ListPersistedAgentsOptions, ): Promise { const child = await this.spawnAppServer(); - const client = new CodexAppServerClient(child, this.logger); + const client = + this.deps._createCodexClient?.(child, this.logger) ?? + new CodexAppServerClient(child, this.logger); try { await client.request("initialize", buildCodexAppServerInitializeParams()); client.notify("initialized", {}); const limit = options?.limit ?? 20; - const response = toObjectRecord(await client.request("thread/list", { limit })); - const threads = Array.isArray(response?.data) ? response.data : []; + // thread/list returns the cheap `cwd` field. When the caller supplied + // a cwd hint we filter here so the per-thread `thread/read includeTurns` + // hydration below only runs for matching threads. Fetch a wider window + // when filtering since most threads will be from other cwds. + const listLimit = options?.cwd ? Math.max(limit, 50) : limit; + const response = toObjectRecord(await client.request("thread/list", { limit: listLimit })); + const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : []; + const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); const descriptors: PersistedAgentDescriptor[] = await Promise.all( threads.slice(0, limit).map(async (thread) => { const threadId = typeof thread.id === "string" ? thread.id : ""; diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 37dcf7b81..737fdc48f 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, test, vi } from "vitest"; -import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; @@ -656,3 +656,87 @@ describe("OpenCode adapter startTurn error handling", () => { } }); }); + +describe("OpenCode persisted sessions", () => { + test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => { + const storageRoot = mkdtempSync(path.join(os.tmpdir(), "opencode-storage-")); + const cwd = path.join(storageRoot, "repo"); + const otherCwd = path.join(storageRoot, "other"); + + writeOpenCodeJson(storageRoot, "session/project-1/ses_old.json", { + id: "ses_old", + directory: cwd, + title: "Old session", + time: { created: 1000, updated: 1000 }, + }); + writeOpenCodeJson(storageRoot, "session/project-1/ses_new.json", { + id: "ses_new", + directory: cwd, + title: "New session", + time: { created: 2000, updated: 3000 }, + }); + writeOpenCodeJson(storageRoot, "session/project-2/ses_other.json", { + id: "ses_other", + directory: otherCwd, + title: "Other cwd", + time: { created: 4000, updated: 4000 }, + }); + writeOpenCodeJson(storageRoot, "message/ses_new/msg_user.json", { + id: "msg_user", + sessionID: "ses_new", + role: "user", + time: { created: 2100 }, + }); + writeOpenCodeJson(storageRoot, "part/msg_user/prt_user.json", { + id: "prt_user", + sessionID: "ses_new", + messageID: "msg_user", + type: "text", + text: "hello world", + time: { start: 2100 }, + }); + writeOpenCodeJson(storageRoot, "message/ses_new/msg_assistant.json", { + id: "msg_assistant", + sessionID: "ses_new", + role: "assistant", + time: { created: 2200 }, + }); + writeOpenCodeJson(storageRoot, "part/msg_assistant/prt_assistant.json", { + id: "prt_assistant", + sessionID: "ses_new", + messageID: "msg_assistant", + type: "text", + text: "hello back", + time: { start: 2200 }, + }); + + const client = new OpenCodeAgentClient(createTestLogger(), undefined, storageRoot); + const descriptors = await client.listPersistedAgents({ cwd, limit: 1 }); + + expect(descriptors).toHaveLength(1); + expect(descriptors[0]).toMatchObject({ + provider: "opencode", + sessionId: "ses_new", + cwd, + title: "New session", + persistence: { + provider: "opencode", + sessionId: "ses_new", + nativeHandle: "ses_new", + }, + }); + 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: "assistant_message", text: "hello back" }, + ]); + + rmSync(storageRoot, { recursive: true, force: true }); + }); +}); + +function writeOpenCodeJson(storageRoot: string, relativePath: string, value: unknown): void { + const filePath = path.join(storageRoot, relativePath); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(value), "utf8"); +} diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 247871a40..d2b6f1284 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,5 +1,7 @@ import type { ChildProcess } from "node:child_process"; +import { readdir, readFile } from "node:fs/promises"; import { homedir } from "node:os"; +import path from "node:path"; import { createOpencodeClient, type AssistantMessage as OpenCodeAssistantMessage, @@ -73,6 +75,7 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { const OPENCODE_BUILD_MODE_ID = "build"; const OPENCODE_FULL_ACCESS_MODE_ID = "full-access"; +const OPENCODE_STORAGE_SESSION_LIMIT = 200; const DEFAULT_MODES: AgentMode[] = [ { @@ -92,6 +95,51 @@ const DEFAULT_MODES: AgentMode[] = [ }, ]; +const OpenCodeStoredSessionSchema = z + .object({ + id: z.string().min(1), + directory: z.string().min(1), + title: z.string().nullable().optional(), + time: z + .object({ + created: z.number().optional(), + updated: z.number().optional(), + }) + .optional(), + }) + .passthrough(); + +const OpenCodeStoredMessageSchema = z + .object({ + id: z.string().min(1), + sessionID: z.string().min(1), + role: z.string().optional(), + time: z + .object({ + created: z.number().optional(), + completed: z.number().optional(), + }) + .optional(), + }) + .passthrough(); + +const OpenCodeStoredPartSchema = z + .object({ + type: z.string().optional(), + text: z.string().optional(), + time: z + .object({ + start: z.number().optional(), + end: z.number().optional(), + }) + .optional(), + }) + .passthrough(); + +type OpenCodeStoredSession = z.infer; +type OpenCodeStoredMessage = z.infer; +type OpenCodeStoredPart = z.infer; + type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" }; type OpenCodeMessageRole = "user" | "assistant"; @@ -706,6 +754,162 @@ function buildOpenCodePromptParts( return output; } +function resolveOpenCodeStorageRoot(): string { + const xdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = + typeof xdgDataHome === "string" && xdgDataHome.trim().length > 0 + ? xdgDataHome + : path.join(homedir(), ".local", "share"); + return path.join(dataHome, "opencode", "storage"); +} + +async function collectOpenCodePersistedAgentsFromStorage( + storageRoot: string, + options?: ListPersistedAgentsOptions, +): Promise { + const sessions = await readOpenCodeStoredSessions(path.join(storageRoot, "session")); + const limit = options?.limit ?? OPENCODE_STORAGE_SESSION_LIMIT; + const candidates = sessions + .filter((session) => !options?.cwd || session.directory === options.cwd) + .sort((left, right) => getOpenCodeSessionTimestamp(right) - getOpenCodeSessionTimestamp(left)) + .slice(0, limit); + + return await Promise.all( + candidates.map((session) => buildOpenCodePersistedAgentDescriptor(storageRoot, session)), + ); +} + +async function readOpenCodeStoredSessions(sessionRoot: string): Promise { + const files = await findJsonFiles(sessionRoot); + const sessions: OpenCodeStoredSession[] = []; + for (const file of files) { + const parsed = await readJsonFile(file, OpenCodeStoredSessionSchema); + if (parsed) { + sessions.push(parsed); + } + } + return sessions; +} + +async function buildOpenCodePersistedAgentDescriptor( + storageRoot: string, + session: OpenCodeStoredSession, +): Promise { + const timeline = await readOpenCodeSessionTimeline(storageRoot, session.id); + 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), + }, + }, + timeline, + }; +} + +async function readOpenCodeSessionTimeline( + storageRoot: string, + sessionId: string, +): Promise { + const messageRoot = path.join(storageRoot, "message", sessionId); + const messageFiles = await findJsonFiles(messageRoot); + const messages: OpenCodeStoredMessage[] = []; + for (const file of messageFiles) { + const parsed = await readJsonFile(file, OpenCodeStoredMessageSchema); + if (parsed?.sessionID === sessionId) { + messages.push(parsed); + } + } + + const timeline: AgentTimelineItem[] = []; + for (const message of messages.sort( + (left, right) => getOpenCodeMessageTimestamp(left) - getOpenCodeMessageTimestamp(right), + )) { + const text = await readOpenCodeMessageText(storageRoot, message.id); + if (!text) { + continue; + } + if (message.role === "user") { + timeline.push({ type: "user_message", text, messageId: message.id }); + } else if (message.role === "assistant") { + timeline.push({ type: "assistant_message", text }); + } + } + return timeline; +} + +async function readOpenCodeMessageText(storageRoot: string, messageId: string): Promise { + const partRoot = path.join(storageRoot, "part", messageId); + const partFiles = await findJsonFiles(partRoot); + const parts: OpenCodeStoredPart[] = []; + for (const file of partFiles) { + const parsed = await readJsonFile(file, OpenCodeStoredPartSchema); + if (parsed?.type === "text" && typeof parsed.text === "string") { + parts.push(parsed); + } + } + + return parts + .sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right)) + .map((part) => part.text?.trim() ?? "") + .filter(Boolean) + .join("\n\n"); +} + +async function findJsonFiles(root: string): Promise { + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return []; + } + + const files = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + return findJsonFiles(entryPath); + } + return entry.isFile() && entry.name.endsWith(".json") ? [entryPath] : []; + }), + ); + return files.flat(); +} + +async function readJsonFile(file: string, schema: z.ZodType): Promise { + try { + return schema.parse(JSON.parse(await readFile(file, "utf8"))); + } catch { + return null; + } +} + +function normalizeOpenCodeSessionTitle(title: string | null | undefined): string | null { + const normalized = title?.trim(); + return normalized ? normalized : null; +} + +function getOpenCodeSessionTimestamp(session: OpenCodeStoredSession): number { + return session.time?.updated ?? session.time?.created ?? 0; +} + +function getOpenCodeMessageTimestamp(message: OpenCodeStoredMessage): number { + return message.time?.created ?? message.time?.completed ?? 0; +} + +function getOpenCodePartTimestamp(part: OpenCodeStoredPart): number { + return part.time?.start ?? part.time?.end ?? 0; +} + export const __openCodeInternals = { buildOpenCodePromptParts, buildOpenCodeModelContextWindowLookup, @@ -1010,10 +1214,12 @@ export class OpenCodeAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly modelContextWindows = new Map(); + private readonly storageRoot: string; - constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) { + constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings, storageRoot?: string) { this.logger = logger.child({ module: "agent", provider: "opencode" }); this.runtimeSettings = runtimeSettings; + this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot(); this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings); } @@ -1199,10 +1405,9 @@ export class OpenCodeAgentClient implements AgentClient { } async listPersistedAgents( - _options?: ListPersistedAgentsOptions, + options?: ListPersistedAgentsOptions, ): Promise { - // TODO: Implement by listing sessions from OpenCode - return []; + return collectOpenCodePersistedAgentsFromStorage(this.storageRoot, options); } async isAvailable(): Promise { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 0051b8d93..05aef2f53 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -17,6 +17,7 @@ import type { AgentTimelineItem, ListModesOptions, ListModelsOptions, + PersistedAgentDescriptor, } from "./agent/agent-sdk-types.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import type { ProviderDefinition } from "./agent/provider-registry.js"; @@ -69,6 +70,7 @@ interface SessionHandlerInternals { handleCheckoutPullRequest(params: unknown): Promise; handleCheckoutPushRequest(params: unknown): Promise; handleCheckoutStatusRequest(params: unknown): Promise; + handleFetchRecentProviderSessions(params: unknown): Promise; handleImportAgentRequest(params: unknown): Promise; describeWorkspaceRecord(...args: unknown[]): Promise; describeWorkspaceRecordWithGitData(...args: unknown[]): Promise; @@ -931,6 +933,181 @@ afterEach(() => { }); describe("session agent import", () => { + test("filters already imported provider sessions by native handle", async () => { + const messages: unknown[] = []; + const cwd = "/tmp/import-filter"; + const importedDescriptor: PersistedAgentDescriptor = { + provider: "claude", + sessionId: "provider-session-imported", + cwd, + title: "Already imported", + lastActivityAt: new Date("2026-04-30T12:00:00.000Z"), + persistence: { + provider: "claude", + sessionId: "provider-session-imported", + nativeHandle: "provider-native-imported", + }, + timeline: [{ type: "user_message", text: "already imported" }], + }; + const visibleDescriptor: PersistedAgentDescriptor = { + provider: "claude", + sessionId: "provider-session-visible", + cwd, + title: "Still importable", + lastActivityAt: new Date("2026-04-30T11:00:00.000Z"), + persistence: { + provider: "claude", + sessionId: "provider-session-visible", + nativeHandle: "provider-native-visible", + }, + timeline: [{ type: "user_message", text: "still importable" }], + }; + const agentManager = { + listAgents: vi.fn(() => [ + { + provider: "claude", + persistence: { + provider: "claude", + sessionId: "stored-session-id", + nativeHandle: "provider-native-imported", + }, + } as ManagedAgent, + ]), + listImportablePersistedAgents: vi + .fn() + .mockResolvedValue([importedDescriptor, visibleDescriptor]), + subscribe: vi.fn(() => () => {}), + }; + const agentStorage = { + list: vi.fn().mockResolvedValue([]), + }; + const session = createSessionForTest({ messages }); + Object.assign(session, { agentManager, agentStorage }); + + await asSessionInternals(session).handleFetchRecentProviderSessions({ + type: "fetch_recent_provider_sessions_request", + requestId: "recent-provider-sessions", + cwd, + providers: ["claude"], + limit: 15, + }); + + expect(agentManager.listImportablePersistedAgents).toHaveBeenCalledWith({ + limit: 200, + providerFilter: new Set(["claude"]), + cwd, + }); + expect(messages).toContainEqual( + expect.objectContaining({ + type: "fetch_recent_provider_sessions_response", + payload: expect.objectContaining({ + requestId: "recent-provider-sessions", + filteredAlreadyImportedCount: 1, + entries: [ + expect.objectContaining({ + providerId: "claude", + providerHandleId: "provider-native-visible", + title: "Still importable", + }), + ], + }), + }), + ); + }); + + test("imports by provider handle id and hydrates title metadata from the first user message", async () => { + const messages: unknown[] = []; + const cwd = "/tmp/imported-agent"; + const timeline: AgentTimelineItem[] = [ + { type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" }, + { type: "assistant_message", text: "I will inspect the provider listing." }, + ]; + const snapshot = { + id: "00000000-0000-4000-8000-000000000633", + provider: "custom-codex", + cwd, + capabilities: TEST_CAPABILITIES, + config: { provider: "custom-codex", cwd }, + createdAt: new Date("2026-04-30T00:00:00.000Z"), + updatedAt: new Date("2026-04-30T00:00:00.000Z"), + availableModes: [], + currentModeId: null, + pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), + pendingReplacement: false, + persistence: { + provider: "custom-codex", + sessionId: "thread-imported", + nativeHandle: "provider-thread-imported", + metadata: { provider: "custom-codex", cwd }, + }, + historyPrimed: true, + lastUserMessageAt: null, + attention: { requiresAttention: false }, + foregroundTurnWaiters: new Set(), + finalizedForegroundTurnIds: new Set(), + unsubscribeSession: null, + internal: false, + labels: {}, + lifecycle: "closed", + session: null, + activeForegroundTurnId: null, + } satisfies ManagedAgent; + const agentManager = { + listAgents: vi.fn(() => []), + subscribe: vi.fn(() => () => {}), + findPersistedAgent: vi.fn().mockResolvedValue({ + provider: "custom-codex", + sessionId: "thread-imported", + cwd, + title: null, + firstPromptPreview: "Trace recent provider sessions", + lastPromptPreview: "Trace recent provider sessions", + lastActivityAt: new Date("2026-04-30T00:00:00.000Z"), + persistence: snapshot.persistence, + }), + resumeAgentFromPersistence: vi.fn().mockResolvedValue(snapshot), + hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), + getTimeline: vi.fn().mockReturnValue(timeline), + setTitle: vi.fn().mockResolvedValue(undefined), + notifyAgentState: vi.fn(), + }; + const agentStorage = { + list: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(null), + }; + const session = createSessionForTest({ messages }); + Object.assign(session, { agentManager, agentStorage }); + + await asSessionInternals(session).handleImportAgentRequest({ + type: "import_agent_request", + providerId: "custom-codex", + providerHandleId: "provider-thread-imported", + cwd, + requestId: "import-thread", + }); + + expect(agentManager.findPersistedAgent).toHaveBeenCalledWith( + "custom-codex", + "provider-thread-imported", + ); + expect(agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith(snapshot.id); + expect(agentManager.setTitle).toHaveBeenCalledWith( + snapshot.id, + "Trace recent provider sessions", + ); + expect(agentMetadataMocks.scheduleAgentMetadataGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + agentManager, + agentId: snapshot.id, + cwd, + initialPrompt: "Trace recent provider sessions\n\nkeep it tight", + explicitTitle: null, + }), + ); + }); + test("sets a provisional title and schedules auto-title generation from the first hydrated user message", async () => { const messages: unknown[] = []; const cwd = "/tmp/imported-agent"; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d9e413e41..0dedd2cff 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -130,6 +130,11 @@ import type { } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; +import { + ImportSessionsRequestError, + listImportableProviderSessions, + normalizeImportAgentRequest, +} from "./agent/import-sessions.js"; import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId as normalizePersistedWorkspaceId, @@ -662,7 +667,7 @@ class VoiceFeatureUnavailableError extends Error { interface BuildImportPersistenceHandleInput { provider: AgentProvider; - sessionId: string; + providerHandleId: string; cwd?: string; } @@ -672,8 +677,8 @@ function buildImportPersistenceHandle( const cwd = input.cwd ?? process.cwd(); return { provider: input.provider, - sessionId: input.sessionId, - nativeHandle: input.sessionId, + sessionId: input.providerHandleId, + nativeHandle: input.providerHandleId, metadata: { provider: input.provider, cwd, @@ -1819,6 +1824,8 @@ export class Session { return this.handleFetchAgents(msg); case "fetch_agent_history_request": return this.handleFetchAgentHistory(msg); + case "fetch_recent_provider_sessions_request": + return this.handleFetchRecentProviderSessions(msg); case "fetch_agent_request": return this.handleFetchAgent(msg.agentId, msg.requestId); case "delete_agent_request": @@ -3172,11 +3179,26 @@ export class Session { private async handleImportAgentRequest( msg: Extract, ): Promise { - const { provider, sessionId, cwd, labels, requestId } = msg; - this.sessionLogger.info({ sessionId, provider }, `Importing agent ${sessionId} (${provider})`); + const normalized = normalizeImportAgentRequest(msg); + if ("error" in normalized) { + this.emit({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: msg.requestId, + error: normalized.error, + }, + }); + return; + } + const { provider, providerHandleId, cwd, labels, requestId } = normalized; + this.sessionLogger.info( + { providerHandleId, provider }, + `Importing agent ${providerHandleId} (${provider})`, + ); try { - const descriptor = await this.agentManager.findPersistedAgent(provider, sessionId); + const descriptor = await this.agentManager.findPersistedAgent(provider, providerHandleId); if (!descriptor && provider === "opencode" && !cwd) { throw new Error( "OpenCode sessions require --cwd when the session cannot be found in persisted agents", @@ -3185,7 +3207,7 @@ export class Session { const handle = descriptor ? applyImportCwdOverride(descriptor.persistence, cwd) - : buildImportPersistenceHandle({ provider, sessionId, cwd }); + : buildImportPersistenceHandle({ provider, providerHandleId, cwd }); const overrides = cwd ? ({ cwd } satisfies Partial) : undefined; await this.unarchiveAgentByHandle(handle); @@ -6685,6 +6707,49 @@ export class Session { } } + private async handleFetchRecentProviderSessions( + request: Extract, + ): Promise { + try { + const result = await listImportableProviderSessions({ + request, + agentManager: this.agentManager, + agentStorage: this.agentStorage, + providerRegistry: this.getProviderRegistry(), + }); + this.emit({ + type: "fetch_recent_provider_sessions_response", + payload: { + requestId: request.requestId, + entries: result.entries, + ...(result.filteredAlreadyImportedCount > 0 + ? { filteredAlreadyImportedCount: result.filteredAlreadyImportedCount } + : {}), + }, + }); + } catch (error) { + const code = + error instanceof ImportSessionsRequestError + ? error.code + : "fetch_recent_provider_sessions_failed"; + const message = + error instanceof Error ? error.message : "Failed to fetch recent provider sessions"; + this.sessionLogger.error( + { err: error }, + "Failed to handle fetch_recent_provider_sessions_request", + ); + this.emit({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code, + }, + }); + } + } + private async handleFetchWorkspacesRequest( request: Extract, ): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 3223dea3e..810f4d525 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -21,6 +21,7 @@ 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"; @@ -62,6 +63,10 @@ interface SessionTestAccess { list(...args: unknown[]): Promise; get(agentId: string): Promise; }; + agentManager: { + listAgents(): unknown[]; + listImportablePersistedAgents(options?: unknown): Promise; + }; workspaceRegistry: { list(...args: unknown[]): Promise; archive(workspaceId: string, archivedAt: string): Promise; @@ -228,6 +233,30 @@ function makeManagedAgent(input: { }; } +function makePersistedProviderSession(input: { + provider: string; + sessionId: string; + nativeHandle?: string; + cwd: string; + title?: string | null; + lastActivityAt: string; + firstPrompt?: string; +}): PersistedAgentDescriptor { + return { + provider: input.provider, + sessionId: input.sessionId, + cwd: input.cwd, + title: input.title ?? 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 }] : [], + }; +} + function agentIdsFromEntries(entries: Array>) { return entries.map((entry) => AgentIdEntrySchema.parse(entry).agent.id); } @@ -1843,6 +1872,238 @@ test("fetch_agent_history_request pages archived historical rows separately", as expect(session.agentUpdatesSubscription).toBeNull(); }); +test("fetch_recent_provider_sessions_request lists importable provider sessions by handle", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests(); + + session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); + session.agentManager.listAgents = () => [ + { + provider: "codex", + persistence: { + provider: "codex", + sessionId: "live-session", + nativeHandle: "live-handle", + }, + }, + ]; + const persistedDescriptors: PersistedAgentDescriptor[] = [ + makePersistedProviderSession({ + provider: "codex", + sessionId: "outside-filter", + nativeHandle: "outside-filter-handle", + cwd: "/tmp/elsewhere", + title: "Outside filter", + lastActivityAt: "2026-04-30T12:05:00.000Z", + }), + makePersistedProviderSession({ + provider: "codex", + sessionId: "stored-session", + nativeHandle: "stored-handle", + cwd: "/tmp/recent", + title: "Already stored", + lastActivityAt: "2026-04-30T12:04:00.000Z", + }), + makePersistedProviderSession({ + provider: "claude", + sessionId: "wrong-provider", + cwd: "/tmp/recent", + title: "Wrong provider", + lastActivityAt: "2026-04-30T12:03:00.000Z", + }), + makePersistedProviderSession({ + provider: "codex", + sessionId: "older-session", + nativeHandle: "older-handle", + cwd: "/tmp/recent", + title: "Older than since", + lastActivityAt: "2026-04-29T23:59:59.000Z", + }), + makePersistedProviderSession({ + provider: "codex", + sessionId: "newer-session", + nativeHandle: "newer-handle", + cwd: "/tmp/recent", + title: "Newer import", + lastActivityAt: "2026-04-30T12:02:00.000Z", + firstPrompt: "newer prompt", + }), + makePersistedProviderSession({ + provider: "codex", + sessionId: "second-session", + nativeHandle: "second-handle", + cwd: "/tmp/recent", + title: "Second import", + lastActivityAt: "2026-04-30T12:00:00.000Z", + firstPrompt: "second prompt", + }), + makePersistedProviderSession({ + provider: "codex", + sessionId: "third-session", + nativeHandle: "third-handle", + cwd: "/tmp/recent", + title: "Third import", + lastActivityAt: "2026-04-30T11:59:00.000Z", + firstPrompt: "third prompt", + }), + makePersistedProviderSession({ + provider: "codex", + sessionId: "live-session", + nativeHandle: "live-handle", + cwd: "/tmp/recent", + title: "Already live", + lastActivityAt: "2026-04-30T12:01:00.000Z", + }), + ]; + // 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) => { + const providerFilter = (options as { providerFilter?: Set } | undefined) + ?.providerFilter; + if (!providerFilter) { + return persistedDescriptors; + } + return persistedDescriptors.filter((d) => providerFilter.has(d.provider)); + }; + session.agentStorage.list = async () => [ + { + id: "stored-agent", + provider: "codex", + cwd: "/tmp/recent", + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + title: "Stored", + labels: {}, + lastStatus: "closed", + persistence: { + provider: "codex", + sessionId: "stored-session", + nativeHandle: "stored-handle", + }, + }, + ]; + + await session.handleMessage({ + type: "fetch_recent_provider_sessions_request", + requestId: "req-recent-provider-sessions", + cwd: "/tmp/recent", + providers: ["codex"], + since: "2026-04-30T00:00:00.000Z", + limit: 2, + }); + + expect(emitted).toEqual([ + { + type: "fetch_recent_provider_sessions_response", + payload: { + requestId: "req-recent-provider-sessions", + entries: [ + { + providerId: "codex", + providerLabel: "Codex", + providerHandleId: "newer-handle", + cwd: "/tmp/recent", + title: "Newer import", + firstPromptPreview: "newer prompt", + lastPromptPreview: "newer prompt", + lastActivityAt: "2026-04-30T12:02:00.000Z", + }, + { + providerId: "codex", + providerLabel: "Codex", + providerHandleId: "second-handle", + cwd: "/tmp/recent", + title: "Second import", + firstPromptPreview: "second prompt", + lastPromptPreview: "second prompt", + lastActivityAt: "2026-04-30T12:00:00.000Z", + }, + ], + filteredAlreadyImportedCount: 2, + }, + }, + ]); +}); + +test("fetch_recent_provider_sessions_request forwards providerFilter to agent manager", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests(); + let capturedOptions: { providerFilter?: Set; limit?: number } | undefined; + + session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); + session.agentManager.listAgents = () => []; + session.agentStorage.list = async () => []; + session.agentManager.listImportablePersistedAgents = async (options?: unknown) => { + capturedOptions = options as { providerFilter?: Set; limit?: number }; + return []; + }; + + await session.handleMessage({ + type: "fetch_recent_provider_sessions_request", + requestId: "req-provider-filter", + cwd: "/tmp/recent", + providers: ["claude"], + }); + + expect(capturedOptions?.providerFilter).toBeInstanceOf(Set); + expect(Array.from(capturedOptions?.providerFilter ?? [])).toEqual(["claude"]); + expect(emitted).toEqual([ + { + type: "fetch_recent_provider_sessions_response", + payload: { + requestId: "req-provider-filter", + entries: [], + }, + }, + ]); +}); + +test("fetch_recent_provider_sessions_request reports filteredAlreadyImportedCount when all candidates are already imported", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests(); + + session.emit = (message) => emitted.push(message as { type: string; payload: unknown }); + session.agentManager.listAgents = () => [ + { + provider: "codex", + persistence: { + provider: "codex", + sessionId: "live-session", + nativeHandle: "live-handle", + }, + }, + ]; + session.agentStorage.list = async () => []; + session.agentManager.listImportablePersistedAgents = async () => [ + makePersistedProviderSession({ + provider: "codex", + sessionId: "live-session", + nativeHandle: "live-handle", + cwd: "/tmp/recent", + title: "Already live", + lastActivityAt: "2026-04-30T12:01:00.000Z", + }), + ]; + + await session.handleMessage({ + type: "fetch_recent_provider_sessions_request", + requestId: "req-all-imported", + cwd: "/tmp/recent", + providers: ["codex"], + }); + + expect(emitted).toEqual([ + { + type: "fetch_recent_provider_sessions_response", + payload: { + requestId: "req-all-imported", + entries: [], + filteredAlreadyImportedCount: 1, + }, + }, + ]); +}); + test("fetch_agent_request still resolves archived historical agents", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); diff --git a/packages/server/src/shared/importable-providers.ts b/packages/server/src/shared/importable-providers.ts new file mode 100644 index 000000000..f04fa2f1c --- /dev/null +++ b/packages/server/src/shared/importable-providers.ts @@ -0,0 +1,6 @@ +/** + * Providers eligible for "import recent session" discovery. ACP-based + * providers (gemini, copilot, generic acp) are excluded because they either + * don't expose persisted history quickly or duplicate other providers. + */ +export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode"] as const; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index cd191fe94..06e03f4eb 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -661,6 +661,21 @@ export type AgentListItemPayload = z.infer; export type AgentStreamEventPayload = z.infer; +export const RecentProviderSessionDescriptorPayloadSchema = z.object({ + providerId: z.string(), + providerLabel: z.string(), + providerHandleId: z.string(), + cwd: z.string(), + title: z.string().nullable(), + firstPromptPreview: z.string().nullable(), + lastPromptPreview: z.string().nullable(), + lastActivityAt: z.string(), +}); + +export type RecentProviderSessionDescriptorPayload = z.infer< + typeof RecentProviderSessionDescriptorPayloadSchema +>; + // ============================================================================ // Session Inbound Messages (Session receives these) // ============================================================================ @@ -905,6 +920,15 @@ export const FetchAgentHistoryRequestMessageSchema = z.object({ .optional(), }); +export const FetchRecentProviderSessionsRequestMessageSchema = z.object({ + type: z.literal("fetch_recent_provider_sessions_request"), + requestId: z.string(), + cwd: z.string().optional(), + providers: z.array(z.string()).optional(), + since: z.string().optional(), + limit: z.number().int().positive().max(200).optional(), +}); + export const FetchAgentRequestMessageSchema = z.object({ type: z.literal("fetch_agent_request"), requestId: z.string(), @@ -1060,8 +1084,10 @@ export const ResumeAgentRequestMessageSchema = z.object({ export const ImportAgentRequestMessageSchema = z.object({ type: z.literal("import_agent_request"), - provider: AgentProviderSchema, - sessionId: z.string(), + provider: AgentProviderSchema.optional(), + providerId: z.string().optional(), + sessionId: z.string().optional(), + providerHandleId: z.string().optional(), cwd: z.string().optional(), labels: z.record(z.string()).optional(), requestId: z.string(), @@ -1680,6 +1706,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AudioPlayedMessageSchema, FetchAgentsRequestMessageSchema, FetchAgentHistoryRequestMessageSchema, + FetchRecentProviderSessionsRequestMessageSchema, FetchWorkspacesRequestMessageSchema, FetchAgentRequestMessageSchema, DeleteAgentRequestMessageSchema, @@ -2287,6 +2314,15 @@ export const FetchAgentHistoryResponseMessageSchema = z.object({ }), }); +export const FetchRecentProviderSessionsResponseMessageSchema = z.object({ + type: z.literal("fetch_recent_provider_sessions_response"), + payload: z.object({ + requestId: z.string(), + entries: z.array(RecentProviderSessionDescriptorPayloadSchema), + filteredAlreadyImportedCount: z.number().int().nonnegative().optional(), + }), +}); + export const FetchWorkspacesResponseMessageSchema = z.object({ type: z.literal("fetch_workspaces_response"), payload: z.object({ @@ -3324,6 +3360,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentStatusMessageSchema, FetchAgentsResponseMessageSchema, FetchAgentHistoryResponseMessageSchema, + FetchRecentProviderSessionsResponseMessageSchema, FetchWorkspacesResponseMessageSchema, OpenProjectResponseMessageSchema, StartWorkspaceScriptResponseMessageSchema, @@ -3454,6 +3491,9 @@ export type FetchAgentsResponseMessage = z.infer; +export type FetchRecentProviderSessionsResponseMessage = z.infer< + typeof FetchRecentProviderSessionsResponseMessageSchema +>; export type FetchWorkspacesResponseMessage = z.infer; export type ScriptStatusUpdateMessage = z.infer; export type OpenProjectResponseMessage = z.infer; @@ -3530,6 +3570,9 @@ export type ActivityLogPayload = z.infer; export type VoiceAudioChunkMessage = z.infer; export type FetchAgentsRequestMessage = z.infer; export type FetchAgentHistoryRequestMessage = z.infer; +export type FetchRecentProviderSessionsRequestMessage = z.infer< + typeof FetchRecentProviderSessionsRequestMessageSchema +>; export type FetchWorkspacesRequestMessage = z.infer; export type FetchAgentRequestMessage = z.infer; export type SendAgentMessageRequest = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index c20791f86..d995c3d6c 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { describe, expect, test } from "vitest"; import { + RecentProviderSessionDescriptorPayloadSchema, SessionInboundMessageSchema, SessionOutboundMessageSchema, WorkspaceDescriptorPayloadSchema, @@ -110,6 +111,125 @@ describe("workspace message schemas", () => { expect(response.type).toBe("fetch_agent_history_response"); }); + test("parses recent provider session descriptors without legacy handle fields", () => { + const parsed = RecentProviderSessionDescriptorPayloadSchema.parse({ + providerId: "custom-codex", + providerLabel: "Custom Codex", + providerHandleId: "thread-1", + cwd: "/tmp/repo", + title: "Resume this", + firstPromptPreview: "first prompt", + lastPromptPreview: "last prompt", + lastActivityAt: "2026-04-30T12:34:56.000Z", + }); + + expect(parsed).toEqual({ + providerId: "custom-codex", + providerLabel: "Custom Codex", + providerHandleId: "thread-1", + cwd: "/tmp/repo", + title: "Resume this", + firstPromptPreview: "first prompt", + lastPromptPreview: "last prompt", + lastActivityAt: "2026-04-30T12:34:56.000Z", + }); + }); + + test("parses fetch_recent_provider_sessions request and response", () => { + const request = SessionInboundMessageSchema.parse({ + type: "fetch_recent_provider_sessions_request", + requestId: "req-recent-provider-sessions", + cwd: "/tmp/repo", + providers: ["my-claude"], + since: "2026-04-30T00:00:00.000Z", + limit: 25, + }); + const response = SessionOutboundMessageSchema.parse({ + type: "fetch_recent_provider_sessions_response", + payload: { + requestId: "req-recent-provider-sessions", + entries: [ + { + providerId: "my-claude", + providerLabel: "My Claude", + providerHandleId: "thread-1", + cwd: "/tmp/repo", + title: "Resume this", + firstPromptPreview: "first prompt", + lastPromptPreview: "last prompt", + lastActivityAt: "2026-04-30T12:34:56.000Z", + }, + ], + }, + }); + + expect(request.type).toBe("fetch_recent_provider_sessions_request"); + expect(request.providers).toEqual(["my-claude"]); + expect(response.payload).toEqual({ + requestId: "req-recent-provider-sessions", + entries: [ + { + providerId: "my-claude", + providerLabel: "My Claude", + providerHandleId: "thread-1", + cwd: "/tmp/repo", + title: "Resume this", + firstPromptPreview: "first prompt", + lastPromptPreview: "last prompt", + lastActivityAt: "2026-04-30T12:34:56.000Z", + }, + ], + }); + }); + + test("parses fetch_recent_provider_sessions response with filteredAlreadyImportedCount", () => { + const response = SessionOutboundMessageSchema.parse({ + type: "fetch_recent_provider_sessions_response", + payload: { + requestId: "req-recent-provider-sessions", + entries: [], + filteredAlreadyImportedCount: 3, + }, + }); + + if (response.type !== "fetch_recent_provider_sessions_response") { + throw new Error("expected fetch_recent_provider_sessions_response"); + } + expect(response.payload.filteredAlreadyImportedCount).toBe(3); + }); + + test("parses new and legacy import agent requests", () => { + const newRequest = SessionInboundMessageSchema.parse({ + type: "import_agent_request", + requestId: "req-import-new", + providerId: "custom-codex", + providerHandleId: "thread-1", + cwd: "/tmp/repo", + }); + const legacyRequest = SessionInboundMessageSchema.parse({ + type: "import_agent_request", + requestId: "req-import-legacy", + provider: "custom-codex", + sessionId: "thread-1", + cwd: "/tmp/repo", + }); + + expect(newRequest).toEqual({ + type: "import_agent_request", + requestId: "req-import-new", + providerId: "custom-codex", + providerHandleId: "thread-1", + cwd: "/tmp/repo", + }); + expect(legacyRequest).toEqual({ + type: "import_agent_request", + requestId: "req-import-legacy", + provider: "custom-codex", + sessionId: "thread-1", + cwd: "/tmp/repo", + }); + }); + test("parses open_project_request", () => { const parsed = SessionInboundMessageSchema.parse({ type: "open_project_request",